Compare commits

...
931 Commits
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 f7c88af1de Fix #482: Smart Guides land resize handles on anchors and snap the transform tools' drags
Bounding-box resizes with the Selection and Free Transform tools already lined
their edges up with the other objects (#355, after v0.4.0), but a handle never
landed on another object's anchor point, nothing was labelled, and the Rotate,
Scale, Shear and Reflect tools didn't snap their drags at all.

- Targets::snap_scale: the dragged corner (free, or along its diagonal with
  Shift) or side lands on the nearest anchor or centre within the snapping
  tolerance, labelled "anchor"/"center"; alignment snaps keep their line and
  now say "align", as an artboard's resize handle does. Alt (from the centre)
  and rotated boxes work the same way.
- The transform tools start a drag from the anchor or centre they grabbed (the
  point a click would set) and snap the pointer as drawing tools do (Smart
  Guides, the grid, pixels or Snap to Point), so a corner dragged onto another
  object's anchor scales exactly onto it.
- Factor the nearest anchor/centre lookup (Targets::nearest_point) and the
  transform tools' drag teardown (end_drag).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:21:31 +07:00
BFlat 70b1bbf679 Merge pull request #496 from storytold/fix/issue-491
Part of #491: pen taps click where they tap; Linux note for tablet pens on Wayland
2026-10-08 20:20:11 +07:00
bflatasticandClaude Opus 5.5 744edddaea Part of #491: pen taps click where they tap; Linux note for tablet pens on Wayland
Pens on KDE Plasma 6.3+ under Wayland don't reach the app: winit 0.30 binds no
Wayland tablet protocol (zwp_tablet_v2), and since Plasma 6.3 KWin no longer
emulates pointer input for such clients (kwin!6336), so the pen moves KWin's own
cursor while the window gets no motion or presses. The fix is upstream (winit
0.31's tablet input, winit#4318, once eframe moves to it). docs/development.md
and the README give the workarounds: run under XWayland (WAYLAND_DISPLAY=), or
set KWIN_WAYLAND_EMULATE_TABLET=1 for the session.

On our side, a pen or finger (winit Touch, emulated as a mouse by egui-winit)
lifts with an egui PointerGone in the same frame. A tap whose press and lift
arrived in one frame had no hover position, so the canvas ignored it, and a
drag ending without a pointer position ended at the canvas's centre. The press
and double-tap now fall back to the interact position, and the release to the
last position seen during the drag. Headless frame tests replay the events a
pen produces: a stroke with pressure reaching Use Pressure Pen, and a one-frame
tap that selects without moving (fails without the fix).

ROADMAP: Last updated, the Platforms row, the hardening gap and the graphics
processor/input bullet (pen pressure reaches the tools on Windows only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:14:18 +07:00
Brandon ThomasandClaude Opus 5.5 d9fc3b6505 Bump version to 0.5.0 (from 0.4.0) for release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:13:01 -04:00
BFlat c61683bfd6 Merge pull request #478 from wstock/fix/issue-394-pixel-grid
Part of #394: Show Pixel Grid (Above 600% Zoom)
2026-10-08 19:10:19 +07:00
BFlat 21c3581282 Merge pull request #477 from wstock/fix/issue-394-anti-aliased-artwork
Part of #394: Anti-aliased Artwork
2026-10-08 19:08:05 +07:00
BFlat c877607676 Merge pull request #475 from wstock/fix/issue-394-type-path-only
Part of #394: Type Object Selection by Path Only
2026-10-08 19:07:03 +07:00
bflatasticandClaude Opus 5.5 7578787392 Canvas grid: turn with a rotated view, no per-frame vectors (review of #478)
The pixel grid draws through the document grid's `grid()`, which assumed an
unrotated view: with Rotate View its lines stayed upright on screen, in the
wrong places, or vanished. It now spans the box round the canvas's corners in
document space and draws each line through the view, so the pixel grid and
the document grid (lines and dots) turn with the art. The lines are iterated
rather than collected, capped, and computed by multiplication instead of
accumulating a step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:00:03 +07:00
wstockandClaude Fable 5.1 aa8e77d09a Part of #394: Show Pixel Grid (Above 600% Zoom)
Preferences › Guides & Grid › Show Pixel Grid (Above 600% Zoom) was stored and
never read. On (the default), in View › Pixel Preview at 600% zoom and above the
canvas draws a translucent line at every document pixel over the art, so the
pixels the art rasterizes to can be seen; nothing below 600%, out of Pixel
Preview, or with the option off. Drawn by the same `grid()` as the document
grid, at a 1 pt spacing.

A headless frame test counts the pixel grid's lines at 800%, 500% and 600%, in
and out of Pixel Preview and with the option off, and checks they are drawn
over the art.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 19:00:03 +07:00
wstockandClaude Fable 5.1 349e1eac3c Part of #394: Anti-aliased Artwork
Preferences › General › Anti-aliased Artwork was stored and never read. Off, the
canvas renders the art with `AntiAlias::None` — a pixel is painted when the art
covers at least half of it, raster effects and pattern tiles staying smooth — and
on (the default) with `AntiAlias::Art`, as before. The preference is part of the
canvas's cache key, so changing it re-renders. Exports keep their own
Anti-aliasing option.

A headless frame test reads the art's raster back out of the frame's texture
upload: edge pixels partly covered with the option on, none off, and smooth
again once it is back on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:59:51 +07:00
bflatasticandClaude Opus 5.5 afb438367d Type Object Selection by Path Only: every baseline, Type tool and Eyedropper (review of #475)
- Type is picked on the baseline of any of its lines (point type and area
  type, besides area type's frame), as in Illustrator, not only the first
  one: the layout's baselines are cached with its bounds
  (`TextObject::cached_baselines`, from `TextLayout::baselines`), with the
  first one across the bounds as the fallback. Vertical type's runs down
  each column's centre line.
- Type in perspective is picked where it is drawn (through its projection).
- The bounds rule type out before its path is built, so hovering stays
  free of allocations away from type.
- The Type tools still edit type clicked among its characters (they made
  new type there with the preference on), and the Eyedropper still samples
  it there, as it does inside fills with Object Selection by Path Only.
- Tests for all of it; docs/mcp.md and ROADMAP say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:59:47 +07:00
wstockandClaude Fable 5.1 beea693ee1 Part of #394: Type Object Selection by Path Only
Preferences › Type › Type Object Selection by Path Only was stored and never read.
On, the Selection tools pick type on its type path only — point type's baseline
(the line the selection marks under it, across its layout's width), area type's
frame, type on a path's path — within the selection tolerance; a click among the
glyphs or inside the frame selects nothing. Off (the default), anywhere in the
type's bounds selects it, as before.

`HitOptions` gains `type_path_only`, set by the tools' context (`hit_options()`)
and the canvas's `hit_at` from the preference, so the Selection tools, Cmd-click
behind, object highlighting and the context menu agree. Tests at the hit-test
level (point, area and on-path type) and through the Selection tool with
`prefs.set`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:59:47 +07:00
BFlat 40d6975b02 Merge pull request #489 from storytold/fix/issue-487
Fix #487: Keyboard Shortcuts records chords with modifiers
2026-10-08 18:58:11 +07:00
bflatasticandClaude Opus 5.5 6dc6b8600b Fix #487: Keyboard Shortcuts records chords with modifiers
egui reports a modifier's own key press (ShiftLeft, ControlLeft…) as a key event, and the
recorder took the first key press it saw, so pressing Shift+A saved `ShiftLeft` as the chord: a
shortcut that can never fire. The recorder now skips modifier key presses, so the chord completes
on the first other key with the modifiers held (Esc still cancels). A modifier is never a chord's
key in the shortcut parser either, so `shortcuts.set` refuses one, and overrides saved that way by
older versions are dropped on start, giving the default back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:55:02 +07:00
BFlat 4974d658e5 Merge pull request #481 from storytold/fix/eps-vector-import
EPS import: other apps' EPS open as vectors, a preview that stands in says why
2026-10-08 18:31:59 +07:00
bflatasticandClaude Opus 5.5 4827780f03 Fix #474: Illustrator 8 EPS gradients open (executable strings, load by integer keys)
Stock EPS files saved in the Illustrator 8 format failed with "typecheck in `a
dictionary`": its gradient procset builds shading dictionaries with `bd` …
`ed`, where `ed` is `(>>) cvx` (or a Level 1 dictionary builder as a
string), and `cvx` left a string literal, so `shfill` got a string. Its
`discard` procedure also `load`s procedures by integer keys, which `load`
refused.

- Executable strings: `cvx` makes a string executable (`xcheck` true,
  `cvlit` back, still a `stringtype` for `length`/`get`/`forall`/
  `getinterval`); run by name or met in a procedure, it runs its text as a
  program, every time (an executable file shares the same runner).
- `load` takes any key (integers, booleans), as `where` already did.
- Test: an Illustrator 8-style gradient procset with both `ed` forms and
  `discard`; the import fuzz vocabulary has executable strings and integer
  keys. An Illustrator 2026 file saved in the Illustrator 8 format (made
  here, not committed) now opens as vectors with its radial gradient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:24:05 +07:00
bflatasticandClaude Opus 5.5 9f3b992ecc EPS import: other apps' EPS open as vectors, a preview that stands in says why
Discord feedback: "EPS files are always rasterized: they open as bitmaps, in
other programs as vectors". Every EPS saved by Illustrator 2026 fell back to
its TIFF preview (`gcheck` was unknown), and cairo/Inkscape files stopped at
their first image (`status`), dropped masked images (ImageType 3) and failed
on mesh gradients; the warning didn't say where the program failed.

Fallback never silent:
- The warning names the PostScript error, the operator that raised it and
  the named procedures it ran in, innermost first ("a PostScript error
  (typecheck in `add`), in `inner` in `outer`"); errors caught by `stopped`
  aren't reported. It reaches the status bar, document.open, app.open and
  file.open (which now return document.open's result, so MCP's open_file over
  the control channel gets it), and `vectorcraft-cli info` ("warnings").
- A file whose PostScript can't be read opens as its TIFF preview, else its
  Windows metafile preview (as vectors, scaled to the bounding box), else its
  EPSI bitmap. es @msg rows for the new messages.

Interpreter:
- Operators programs probe or use: gcheck/scheck, rootfont,
  currentcolorrendering, currenthsbcolor, status, token, read,
  bytesavailable, fileposition/setfileposition, resetfile, write*, pdfmark
  (marks dropped, as Ghostscript does), execform, nulldevice, strokepath,
  pathforall, user paths (ufill/ueofill/ustroke/uappend/upath/setbbox, the
  encoded form too), setcachedevice2, findcolorrendering, cache, device and
  VM queries, revision; (%stdout) (w) file gives a sink.
- `//name` is replaced by its value when read, inside procedures too.
- Tiling patterns become pattern swatches (their PaintProc drawn in pattern
  space, one cell; uncoloured ones in the colour under them; makepattern
  copies share one swatch); nested patterns stop at 8 levels.
- Type 3 fonts draw their glyphs (BuildGlyph/BuildChar, widths from
  setcachedevice/setcharwidth) as outlines grouped per string, spaced by
  ashow/widthshow/awidthshow/xshow/yshow/xyshow/kshow; glyphshow and
  stringwidth/cshow use them.
- Shadings of types 1 and 4-7 become gradient meshes (data from arrays,
  strings or files, packed or not, flags sharing edges), as fills through a
  shading pattern too; sampled functions (type 0) are evaluated;
  GradientMesh::coons/triangle are shared with PDF import.
- ImageType 3 masked images, interleaved by sample, by row or in separate
  sources; ReusableStreamDecode reads its data when made; Flate/LZW PNG and
  TIFF predictors.
- Bounded: DeviceN at 32 inks, sampled functions at 2^24 samples and 32
  outputs, 4096 patches a shading, strokepath ignores dashes finer than 1e5
  pieces, Type 3 glyphs nest 8 deep (a glyph that shows itself overflowed the
  stack before the limit), 64-bit index maths for 32-bit targets.

Tests: a synthetic program per generator (cairo/Inkscape, its Type 3 fonts,
matplotlib, CorelDRAW, Affinity, LibreOffice, Ghostscript eps2write,
Illustrator procsets), meshes and function shadings, masked images, fault
messages, probing operators, metafile/EPSI previews, `//name`, user paths,
hostile patterns/glyphs/shadings (run against main, 12 of the 14 generator
and feature tests fail; CorelDRAW- and LibreOffice-style programs already
read there). The import fuzz vocabulary covers the new operators. Checked
against real files made here with Inkscape 1.4 (cairo) and Illustrator 2026
(its own format and the CS-to-CC compatibility formats; all fell back or
stopped early on main and open as vectors now; its Illustrator 8 format
needs the next commit); those files aren't committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:23:36 +07:00
BFlat 690e770c92 Merge pull request #480 from storytold/fix/issue-479
Fix #479: Add/Delete Anchor Point tool icons drawn from the Pen tool's
2026-10-08 18:22:47 +07:00
bflatasticandClaude Opus 5.5 13d5645052 Fix #479: Add/Delete Anchor Point tool icons drawn from the Pen tool's
The two tools had hand-drawn nibs that didn't match the Pen tool's icon and read as distorted
next to it. They now use Lucide's pen-tool (the Pen tool's own icon) with a plus or minus badge,
so the Pen group reads as one family; the Remove Anchor Points buttons pick up the minus one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:17:59 +07:00
BFlat 6f455f9616 Merge pull request #476 from storytold/fix/issue-472
Part of #472: Escape leaves Presentation Mode; dropped documents open as tabs; .ai off-artboard note
2026-10-08 18:06:12 +07:00
bflatasticandClaude Opus 5.5 e98a880e75 Part of #472: Escape leaves Presentation Mode; dropped documents open as tabs; .ai off-artboard note
Presentation Mode hides the menus and panels, had no Escape and was saved with the
preferences, so it came back on every restart with no way out. Escape now leaves it (as in
Illustrator), F from it goes back to Normal Screen Mode, and the screen mode is no longer
saved: the app always starts in Normal Screen Mode, and preferences saved in Presentation
Mode by older versions open normally. ui.inspect reports `screenMode` on its own.

Desktop file drags carry no pointer position or modifiers (winit 0.30 on Windows, macOS
and X11), so every drop on a window with a document open was taken for the middle of the
canvas and placed. Where the platform doesn't say, a dropped document (native, SVG, PDF,
.ai, EPS, DXF, EMF/WMF) now opens as a tab of its own and a picture or text is placed in
the middle of the view; pictures dropped together with documents are placed first, so the
last document opened is shown. The web, whose drag events give the position, keeps
Illustrator's choice: placed on the canvas, opened off it (the tab bar).

Illustrator's PDF-compatible part leaves out art outside the artboards (it lives in the
private data alone, which isn't read), so it can't open; importing a file with that
private data now says so in a note. Off-page art a PDF does draw already opens on the
pasteboard; a test keeps it so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:01:12 +07:00
BFlat 44e4e63f22 Merge pull request #463 from imachus/pr/ja-burasagari
Type: burasagari — a Japanese comma or full stop ending a line may hang outside it
2026-10-08 17:42:42 +07:00
bflatasticandClaude Opus 5.5 10a87311d3 Type: burasagari labels Regular/Force, full-width commas and full stops hang, MCP docs (review of #463)
- The Paragraph panel menu's Burasagari submenu reads None / Regular / Force, as the reference
  app's help names them (`text.setFormat` keeps "none"/"standard"/"forced", its scripting names);
  Reset Panel goes back to new type's Regular.
- The full-width ,.and half-width 、。 hang too; Latin commas and full stops never do, so Latin
  text keeps its breaks and composer with new type's default (tested). The spaces after a hanging
  mark stay on its line.
- docs/mcp.md: a Burasagari section. ROADMAP: wording, Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:34:05 +07:00
imachusandClaude Opus 5.5 eb38894759 Type: new type takes Standard burasagari, as in Illustrator; Latin paragraphs keep the every-line composer
- New type (the Type tools, `text.create`, area type and type on a path) takes Standard
  burasagari, Illustrator's default (observed in its Paragraph panel menu). Documents from before
  it and imported text keep None.
- Only a paragraph with a Japanese comma or full stop is composed line by line, so Latin text keeps
  the every-line composer with the new default (tested).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:34:05 +07:00
imachusandClaude Opus 5.5 08e7a4f713 Type: burasagari — a Japanese comma or full stop ending a line may hang outside it
Paragraph panel menu › Burasagari › None / Standard / Forced (with the East Asian options) and
`text.setFormat {burasagari}`:

- None: a comma or full stop that doesn't fit goes to the next line with the character before it.
- Standard: one that doesn't fit hangs outside the line; one that fits stays inside.
- Forced: one ending an area type line always hangs, and the rest of the line fills the measure.

Only 、 and 。 hang (closing brackets don't), in the width their mojikumi gives them. Horizontal
and vertical. Saved only when not None; new type keeps None. Paragraphs with burasagari are
composed line by line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:34:05 +07:00
BFlat 58330fba83 Merge pull request #473 from storytold/feat/pen-anchor-editing
Pen and anchors: Alt edits one handle, cut and convert selected anchors from the Control bar
2026-10-08 17:26:26 +07:00
BFlat 47209d3724 Merge pull request #471 from storytold/feat/control-bar-images
Control bar: image controls (Image Trace, Embed, Crop, Mask) when an image is selected
2026-10-08 17:26:08 +07:00
bflatasticandClaude Opus 5.5 253249717b Pen and anchors: Alt edits one handle, cut and convert selected anchors from the Control bar
Discord feedback: Alt with the Pen didn't move a direction handle on its own, and there was no
way to break a path at an anchor and pull the ends apart.

- Pen: Alt held over a handle end or an anchor of a selected path (the one being drawn too) hands
  the gesture to the Anchor Point tool, with the convert cursor: dragging a handle moves it alone,
  clicking a smooth anchor makes it a corner, dragging an anchor pulls out symmetric handles.
  Drawing goes on afterwards. Handles hidden by a partial anchor selection aren't grabbed.
- path.cutAtAnchors cuts at the direct-selected anchors in one undo step: a closed path opens
  there, an open one becomes one path per piece, and one anchor of each cut stays selected so a
  drag pulls the path apart. It answers {ids} and refuses (no undo step) when only open ends are
  selected. path.convertAnchors checks `to`, skips stale anchor refs instead of indexing past
  them, and keeps a smooth anchor's handles when converting to smooth.
- SubPath::cut_at and SubPath::smooth_anchor (geom) now back path.split, path.cutAtAnchors,
  path.convertAnchor and path.convertAnchors, replacing three copies of that logic.
- Control bar and Properties panel: "Convert:" corner/smooth and "Anchors:" remove, connect
  (Join), cut buttons when anchors are selected; Cut Path on the task bar and the context menu.
  New original icon dc-anchor-smooth.svg (ASSETS.md row).
- ja, zh-hans, zh-hant and es strings (cs for the context menu item), es @msg rows; docs/mcp.md
  section; ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:16:12 +07:00
bflatasticandClaude Opus 5.5 d6f8cc879f Control bar: image controls (Image Trace, Embed, Crop, Mask) when an image is selected
Discord feedback: Image Trace and the other image options were hidden when an image was
selected; copy, paste, Image Trace should work from the Control bar as in Illustrator.

- Control bar, one image selected: Linked File/Embedded, file name, colour mode and PPI, then
  Embed (+ Edit Original) or Unembed…, an Image Trace split button (click: Default preset;
  arrow: trace with any preset), Mask and Crop Image, in place of Fill and Stroke.
- Control bar, one Image Trace object selected: "Image Tracing", its Preset (choosing another
  traces it again), the Image Trace panel and Expand.
- Properties panel: Image Trace (split), Crop Image and Mask in Quick Actions for an image; an
  Image Trace section (Preset, Expand, Release) for an Image Trace object. Embed/Unembed buttons
  are shared with the Control bar (place::link_buttons).
- New engine command object.maskImage ("Mask"): clips the selected image with a clipping path
  on its outline (rotated with it) and selects that path so its handles crop; one undo step.
- widgets::split_button (flat button with a menu arrow).
- Translations for the new strings (es, ja, zh-hans, zh-hant; "Mask" also pt-br, cs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:15:48 +07:00
BFlat 87ba4f6454 Merge pull request #465 from switch87/desktop-log-file
Desktop: write log records to standard error and a rotating log file
2026-10-08 17:15:25 +07:00
BFlat aae7179b72 Merge pull request #470 from storytold/feat/ai-import-groups
AI import: groups and nested groups survive
2026-10-08 17:15:11 +07:00
bflatasticandClaude Opus 5.5 20ed00784f Desktop: report an unusable log folder through the logger, ROADMAP (review of #465)
The "no log file" message went through eprintln!, which panics when
standard error is a broken pipe; log::warn! reaches standard error the
same way (the file sink has already given up) and never panics.
ROADMAP: the log file in "Shipped so far" and "Last updated".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:07:24 +07:00
Gert Pellin ba9edcdc7f Desktop: write log records to standard error and a rotating log file
The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: the graphics adapter in use, a lost graphics
device, the engine guard's report of a recovered panic. A launch from a
desktop menu has no terminal either, so there was nothing to attach to a
bug report.

apps/vectorcraft/src/logging.rs (no new dependency):
- standard error plus logs/vectorcraft.log next to the preferences
  (~/.config/vectorcraft/logs, or under $XDG_CONFIG_HOME); each launch
  rotates the previous log to vectorcraft.1.log and .2.log, so the log of
  a run that crashed survives the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
  (bounded) and written once the file is attached
- default levels: info for vectorcraft*, warn for everything else;
  RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
  error as the only sink
- runs with VECTORCRAFT_NO_PREFS (agents' test runs) log to standard
  error only, so they don't rotate away the user's own logs

The logger is installed first in main and the file is opened after
argument parsing (--version leaves no file). Log messages of this crate
drop their "vectorcraft: " prefix, which the record's target now shows.
vectorcraft-cli keeps its own MCP logger (vectorcraft_mcp::logging).
Documented in docs/development.md (Logs, Environment variables).
2026-10-08 17:07:08 +07:00
bflatasticandClaude Opus 5.5 0bdce8c633 AI import: groups and nested groups survive
Legacy PostScript .ai files (Illustrator 3-8, and their EPS) open through
the EPS interpreter, which only recorded what was drawn: every group came
in flattened. In a file in Illustrator's format (its %AI comments, or
Illustrator named as the creator) the `u` ... `U` it writes at the top
level now open and close groups, whatever its prolog defines them as, so
groups come in nested as they were, between and inside clipping groups.

clipnest's Clip carries an optional region: without one it is a plain
group, nested in the same chain as the clips (ids given in opening order,
so a chain of clips and groups sorts by id).

Clips nested without end used to nest clipping groups past the stack
(a stack overflow in EPS and EMF/WMF import): clips and groups now nest
at most 128 deep with a warning, and each object's chain counts against
the EPS reader's drawing budget.

PDF-compatible .ai (Illustrator 9+) can't get the same: its PDF content
draws each object on its own and doesn't mark plain groups (only layers,
clipping groups and groups with opacity, blending or a mask, which
already come back); the groups live only in the private data, which is
out of scope. Documented in ROADMAP and docs/mcp.md.

Addresses Discord feedback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:06:57 +07:00
BFlat d66a8b6127 Merge pull request #468 from storytold/feat/type-conversion-widget
Type: the point/area type widget converts between them; area type made by dragging resizes
2026-10-08 17:02:58 +07:00
bflatasticandClaude Opus 5.5 940285dd9e Type: the point/area type widget converts between them; area type made by dragging resizes
Discord feedback: type in a frame dragged with the Type tool could not be resized or switched
between area and point type the way the reference app allows.

- The Selection tool shows the type widget beside a single selected point or area type object:
  a small circle outside the middle of its bounding box's right side, hollow on point type and
  filled on area type (vectorcraft_tools::typewidget, drawn by the canvas from the same geometry).
  Hovering it shows the arrow with a type badge; its clicks neither drag nor deselect, and a
  double-click runs type.convertToAreaType / type.convertToPointType (the Type menu's commands),
  keeping the text and its styles in place, one undo step, reachable over MCP.
- Resizing area type made by dragging already works on main (#252): the bounding box resizes the
  type area and the text reflows. A regression test now covers the reported flow.
- docs/mcp.md and ROADMAP.md describe the widget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:54:12 +07:00
BFlat 852848f4ed Merge pull request #467 from storytold/feat/properties-fill-stroke-shortcuts
Properties: Fill and Stroke; Shift+F7 opens Align; Alt+wheel zooms
2026-10-08 16:53:31 +07:00
bflatasticandClaude Opus 5.5 c5b5dbb6ad Properties: Fill and Stroke; Shift+F7 opens Align; Alt+wheel zooms
Discord feedback.

- Properties panel: the Appearance section's Fill and Stroke rows now use the
  Control bar's widgets: the chips with their chevron and swatch/mixer
  popover, the underlined Stroke link that opens the Stroke panel as a
  popover (stroke::link, shared with the Control bar) and the weight spinner
  with its presets (stroke::weight_field, which also follows the Type tool's
  selected characters). With nothing selected the Document view shows them
  too, as the Control bar does: they set up the next object drawn.
- Window menu panel keys as in Illustrator: Brushes F5, Layers F7, Align
  Shift+F7, Transform Shift+F8, Info Cmd+F8, Pathfinder Cmd+Shift+F9,
  Symbols Cmd+Shift+F11, Character Cmd+T, Paragraph Cmd+Alt+T, Tabs
  Cmd+Shift+T, OpenType Cmd+Alt+Shift+T (beside the existing Color, Color
  Guide, Appearance, Graphic Styles, Stroke, Gradient, Transparency and
  Attributes keys). The dock's tabs (Properties, Layers, Libraries) join the
  panel shortcut table and the Keyboard Shortcuts editor through
  state::all_panels, which window.panel's name lookup and the catalog's
  label list use too.
- Alt-wheel (Option on the Mac) zooms about the pointer whatever General >
  Zoom with Mouse Wheel says: egui spreads a notch over a few frames, and the
  rest of an Alt turn scrolled once Alt was up (also over the control
  channel's ui.wheel); the turn's own modifiers now decide. Pinch zoom is
  covered by the test too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:46:34 +07:00
BFlat a9b28cc22a Merge pull request #464 from storytold/revert/gentoo-readme
README and contributors: drop the Gentoo overlay section
2026-10-08 16:41:43 +07:00
bflatasticandClaude Opus 5.5 bde3a222bb README and contributors: drop the Gentoo overlay section and its credit entry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:41:00 +07:00
BFlat 752a67e16c Merge pull request #462 from switch87/readme-gentoo
README: install on Gentoo from the ::snakebyte overlay
2026-10-08 16:31:40 +07:00
Gert Pellin 746d2a6b07 Contributors: credit switch87 as Gert Pellin 2026-10-08 16:30:04 +07:00
Gert Pellin 39a2338b2f README: install on Gentoo from the ::snakebyte overlay 2026-10-08 16:30:04 +07:00
BFlat 2a03e9b87f Merge pull request #460 from storytold/fix/issue-457
Fix #457: paste screenshots from the clipboard on macOS, Windows and the web
2026-10-08 16:19:37 +07:00
bflatasticandClaude Opus 5.5 8f771faa9e Fix #457: paste screenshots from the clipboard on macOS, Windows and the web
A screenshot on the clipboard didn't paste everywhere:
- macOS: the Paste menu item (which takes Cmd+V there) was enabled for text
  only, so with nothing copied in the app Cmd+V did nothing; and arboard
  reads the pasteboard's TIFF only. The pasteboard's types now enable Paste,
  and its PNG, else TIFF, pastes as it is.
- Windows: bitmaps came through CF_BITMAP and GetDIBits, which drops alpha.
  Paste now reads PNG, else CF_DIBV5, else CF_DIB, decoded with their colour
  masks and tables (a BMP file header in front, which image's headerless
  decoder gets wrong for masks after a BITMAPINFOHEADER), opaque when the
  alpha is zero throughout as screenshots leave it, capped at 32768 px a side.
- Web: only SVG text pasted. The page's paste event now hands the first
  picture or file that is art to the app (VectorcraftApp::paste_from_host),
  pasted with the chord held, like the desktop's paste keys.
- Linux reads image/png already; its Paste menu item still waits for text
  (no cheap way to ask arboard for a bitmap), the paste keys take one.

Bitmaps paste as embedded images at the view centre, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:15:20 +07:00
BFlat f22c100cc5 Merge pull request #459 from storytold/fix/issue-458
Fix #458: Font Size and Leading combo boxes in the Character and Properties panels and the Control bar
2026-10-08 16:13:01 +07:00
bflatasticandClaude Opus 5.5 0c4df274aa Fix #458: Font Size and Leading combo boxes in the Character and Properties panels and the Control bar
The Properties panel's Font Size and Leading were plain typed fields and the
Control bar had no type controls. All three places now use the shared
editable combo (`widgets::spin_field`: typing, arrow and spinner stepping,
label scrubbing, a preset dropdown in the type unit):

- Character panel: the size and leading grid is shared with the Properties
  panel (`character::metrics_grid`), which now shows the font style, Font
  Size, Leading, Kerning and Tracking as combos and applies them to the
  Type tool's selected characters as the Character panel does.
- Control bar: with type selected or edited, a Character link (the Character
  panel in a popover), the font family and style menus and the Font Size combo.
- Presets as Illustrator lists them: sizes 6-72 pt; Leading has Auto first
  (`widgets::spin_field_auto`, `text.setStyle {leading: "auto"}`) and then
  the size ladder instead of 120% values; Kerning and Tracking -100...200.
- Font Size, Leading, Kerning and Tracking show blank where the selected
  text's values differ (`character::shared`); the spinner does nothing on a
  blank field instead of jumping to the step size.

Each pick is one `text.setStyle` / `text.setRangeStyle` step, so MCP and the
control channel are unchanged. Translations for the two new strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:10:14 +07:00
BFlat fa405da794 Merge pull request #453 from wstock/fix/issue-394-smart-guides
Part of #394: Smart Guides display options and Snapping Tolerance
2026-10-08 15:51:01 +07:00
bflatasticandClaude Opus 5.5 42fced4725 Smart Guides: "align" also on a handle already in line, Free Transform's move offset and slice readouts follow Measurement Labels (review of #453)
- `Targets::align_rects` tells `snap_point_with` an axis lined up even when
  nothing moved or Alignment Guides hide the line, so the "align" label stays.
- Free Transform's move offset is a measurement label (as the Selection
  tool's), its scale/rotate readouts Transform Tools; slice move/resize
  readouts follow Measurement Labels.
- Snap to Point (Smart Guides off) keeps the default look for moves too, as
  for drawn points and guides.
- Test: guides dragged out of a ruler (#451) follow Snapping Tolerance,
  Anchor/Path Labels and Measurement Labels; docs/mcp.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:46:15 +07:00
wstockandClaude Fable 5.1 14d7b3ddce Part of #394: Smart Guides display options and Snapping Tolerance
Color, Alignment Guides, Anchor/Path Labels, Measurement Labels,
Transform Tools, Object Highlighting and Snapping Tolerance are read:

- tools: `guides::GuideStyle` on `Targets` (`styled(cx)`) colours the
  lines and labels and leaves them out when their option is off, the
  snap itself unchanged; `ToolContext` gains the six fields and
  `snap_tol()`, replacing the 5 px literal for every smart guide snap
  (drawn points, moves, bounding-box handles, ruler guides, artboards,
  reference points). The tools gate their `Overlay::Measure` readouts on
  Measurement Labels (drawing, moving, ruler guides, artboards) or
  Transform Tools (scaling, rotating, Free Transform, transform tools).
- engine: `with_tool_cx` fills them from `Prefs` (the colour parsed, magenta
  as fallback); the colour's default is `#ff3dfc`, what the tools drew.
- ui-egui: the hover outline needs Object Highlighting and Smart Guides.
- Tests in tools (guides, select), engine (tests_prefs) and the canvas;
  docs/mcp.md and ROADMAP.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:46:15 +07:00
BFlat d92ade9117 Merge pull request #456 from storytold/fix/issue-455
Fix #455: the toolbar's double arrow and Window › Toolbars switch one/two columns
2026-10-08 15:41:41 +07:00
bflatasticandClaude Opus 5.5 297f6e93d3 Fix #455: the toolbar's double arrow and Window › Toolbars switch one/two columns
The toolbar went to two columns whenever the window was shorter than the
single column (about 990 pt for Basic, more for Advanced), so on most
screens it was always double and the double arrow and the Window menu item
flipped a flag nothing read. The column count is now the user's choice
alone; a window too short for the tools scrolls them (#84).

- window.toolbarColumns takes {double?: bool} (set or toggle, returns the
  new state) and reports `checked`; the double arrow runs it.
- The Window › Toolbars item reads "Double Column" with a check mark;
  catalogs updated (cs, es, ja, pt-br, zh-hans, zh-hant) and an es @msg row
  for the new error.
- Headless tests: the arrow and the command toggle the column count in a
  700 pt window (both fail on main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:38:54 +07:00
BFlat d8e79078e5 Merge pull request #454 from storytold/fix/issue-451
Part of #451: guides dragged out of the rulers snap, artboard guides
2026-10-08 15:28:04 +07:00
bflatasticandClaude Opus 5.5 5d65699043 Part of #451: guides dragged out of the rulers snap, artboard guides
Guides dragged out of a ruler did not snap at all (only Shift to the ruler's
ticks): they now go through the engine (`Session::ruler_guide`, a
`NewGuide` previewing `guide.add`, one undo step) and snap as a moved guide
does: with Smart Guides to the edges, centres (so the sides' midpoints) and
anchors of the art and of the artboards, labelled; with Smart Guides off,
View > Snap to Point pulls new and moved guides onto anchors. Moving art
with the Selection tool already snapped flush to artboard edges and centres
with Smart Guides on; a test now covers it.

Artboard guides: `Guide.artboard` holds an artboard id (written only when
set, so canvas guides save as before and older files open unchanged).
`guide.add {artboard}` makes one, `guide.list` reports it; a guide dragged
out of a ruler with the Artboard tool belongs to the active artboard. It is
drawn, picked and snapped to across its artboard only, moves with it
(`artboard.move`, a pure move in `artboard.setProps`, `artboard.rearrange`),
is copied with it (`artboard.duplicate`, Alt-drag copy) and deleted with it
(`artboard.delete`, the guide selection kept in step).

Guides as Layers rows with their own lock and hide (part d) stay open: ruler
guides are not layer nodes; Lock Guides locks them all, and Make Guides
paths are Layers rows already. Listed in the ROADMAP parity row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:23:18 +07:00
BFlat 5f9d280892 Merge pull request #452 from storytold/fix/issue-446
Fix #446: copy and paste artboards with their art
2026-10-08 15:06:53 +07:00
bflatasticandClaude Opus 5.5 a0819151a0 Fix #446: copy and paste artboards with their art
With the Artboard tool chosen, Copy and Cut take its artboard together with
the art fully inside it (when Move/Copy Artwork with Artboard is on), and
Paste brings both back in one undo step: Paste right of the last artboard,
Paste in Place / in Front / in Back where it was, into this or another
document, the art going back into the layers it came from (Paste on All
Artboards pastes the art alone). The clipboard carries the artboard
(Clipboard::artboard); a pasted artboard keeps its name when it is free.

Duplicate Artboards (the Artboards panel menu, and now a row dragged onto
New Artboard) copies the art too: artboard.duplicate {index?, art?} defaults
to the Artboard tool's artboard and moveArt option and reports the copies.
It shares copy_artboard with the Alt-drag copy (artboard.move {copy}).

New commands reachable over MCP: artboard.copy and artboard.cut
{index?, art?}; edit.copy / edit.cut run them while the Artboard tool is
chosen (and are enabled then with nothing selected). Docs (mcp.md), the
tool and panel docs, translations of the new labels and the ROADMAP follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:02:33 +07:00
BFlat e17699b7ea Merge pull request #450 from storytold/fix/issue-444
Fix #444: tool cursors are OS cursors, so the pointer no longer trails over the canvas
2026-10-08 14:44:05 +07:00
bflatasticandClaude Opus 5.5 f46527e0bd Fix #444: tool cursors are OS cursors, so the pointer no longer trails over the canvas
Over the canvas the window hid the system cursor and painted the tool's
cursor glyph into its own frame. That glyph reached the screen only after
the frame was rendered, queued (two frames by default) and composited, so
on Windows it visibly trailed the real pointer, on any GPU: the "sluggish
mouse" of the report. Frame times were not the cause (0.4 ms empty,
1.5 ms median on a 20 000-path document, and no repaints while idle).

- cursors: the glyphs are collected as shapes, tessellated by egui and
  rasterized into straight-alpha RGBA bitmaps (anti-aliased, hotspot on a
  pixel corner), cached per cursor and display scale so the same Arc is
  handed over every frame and egui-winit keeps its OS cursor.
- canvas: on Windows and Linux the tool cursor goes to the OS through
  ctx.set_cursor_image; the system moves it itself. macOS (bitmaps sized
  in points) and the web (no bitmap cursors in eframe) keep painting it.
- app: the wgpu surface queues one frame instead of two (LOW_LATENCY):
  the GPU only composites the CPU-rendered canvas, so everything drawn in
  the window follows the pointer a frame sooner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:41:22 +07:00
BFlat 1ce3a5c908 Merge pull request #448 from storytold/fix/issue-442
Fix #442: alike circular corners on rectangles that aren't square
2026-10-08 14:40:03 +07:00
BFlat 4254bf915e Merge pull request #441 from DvloperKing/es-errores
i18n: Spanish translations for error and status-bar messages
2026-10-08 14:39:46 +07:00
bflatasticandClaude Opus 5.5 44c7e64e1b Fix #442: alike circular corners on rectangles that aren't square
Live rectangles drew elliptical corners after an uneven scale in v0.4.0;
inconsistent on a rectangle that isn't square:

- doc: LiveShape::fold_scale moves an uneven scale left in `xf` (files
  saved before #291) into the size and radii; `transform` uses it, and
  `folded()`/`corner_style` give Live Corners document units.
- engine: object.setLiveShape folds that scale first, so setting a radius
  or kind (fields, dialog, widgets, MCP) gives circular corners on such
  shapes instead of keeping the ellipses.
- geom: max_corner_radius/fitted_corner_radius are the one limit every
  corner shares (half the shorter side).
- tools: corner widgets sit at the centres of the corners as drawn (a
  radius past the limit put them off the arc, further in along the long
  side), drag in document units on stretched shapes, and outline the
  edited corners in red at the limit, as the reference app does.
- svg: CSS export writes the drawn radii (CSS would draw a radius past the
  limit when its neighbours leave room).
- docs: mcp.md Live Corners, ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:34:37 +07:00
bflatasticandClaude Opus 5.5 7c7ce7f4fb i18n: Spanish messages say aplicación and match the UI's wording (review of #441)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:34:20 +07:00
Eliezer Hernandez f44155e098 i18n: Spanish translations for error and status-bar messages 2026-10-08 14:34:20 +07:00
BFlat 9533707893 Merge pull request #445 from storytold/fix/issue-443
Fix #443: installed fonts outside the font folders and fonts installed while the app runs reach the font menus
2026-10-08 14:34:12 +07:00
bflatasticandClaude Opus 5.5 64cbee2a96 Fix #443: installed fonts outside the font folders and fonts installed while the app runs reach the font menus
The scan read only the platform's font folders, once per session (or on
Character panel menu > Refresh Font List). Fonts Windows registers outside
its font folders (installed as shortcuts, or by programs into their own
folders) never listed, nor did fonts installed while the app was open.

- Windows: the font files registered under the local machine's and the
  user's Fonts registry keys outside the scanned folders are scanned too
  (windows-registry, the windows-rs project's safe registry API).
- Linux/BSD: the XDG data folders' fonts (XDG_DATA_HOME, XDG_DATA_DIRS:
  Flatpak, Snap, NixOS, Guix) and a Flatpak sandbox's host fonts
  (/run/host/fonts, local-fonts, user-fonts); macOS: /Network/Library/Fonts.
- The global database looks the platform's paths up again at each scan,
  and a scan's roots may be font files as well as folders.
- FontDb::installed_fonts_changed compares the folders the last scan read
  (their modification times, and the paths it started from); the app
  checks it when its window comes back to the front and rescans
  (text.rescanFonts) only when fonts were installed or removed meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:03:53 +07:00
BFlat e2a4173337 Merge pull request #440 from storytold/fix/issue-439
Fix #439: Shape Builder erase mode deletes edges, shows what will go and follows Alt live
2026-10-08 13:42:06 +07:00
bflatasticandClaude Opus 5.5 17f66003cf Fix #439: Shape Builder erase mode deletes edges, shows what will go and follows Alt live
Alt already deleted regions and pieces of open lines, but erase mode had
no edges of closed shapes, no distinct pointer, highlighted regions in the
merge-mode grey, only noticed Alt on a mouse move, and an erase drag could
flip mode at release. Like the reference app's erase mode:

- pathops::shape_builder(shapes, edges) returns a BuilderArrangement; with
  `edges` it also cuts the outlines of closed (and filled open) paths
  wherever other paths meet them. pathops::cut_out removes such a piece
  from a path: a closed subpath opens there, an open one splits or
  shortens, a piece all the way round takes its subpath, and a filled open
  path's invisible closing edge never comes back as a visible segment.
  Specks where paths only touch cut nothing; a zero-length input is no
  longer the source of edges running through it. encloses_area is shared
  with the tool's closing of filled open paths.
- BuilderMap (with_edges) hit-tests edges like line pieces (nearest wins);
  an erase gesture that crosses an edge into a region deletes the region,
  not the edge (erased_edges).
- shapeBuilder.merge {erase}: deletes the edges touched within tolerance
  (from every shape along them), after the regions; returns `edges`.
  shapeBuilder.regions lists `edges` (bounds, source). One undo step.
- Shape Builder tool: Alt held switches to erase mode and back on release:
  red mesh/edge highlights of what will be deleted, a minus pointer
  (Cursor::ShapeBuilder / ShapeBuilderErase, a plus in merge mode); a drag
  keeps the mode it started in. The canvas re-hovers the tool when a
  modifier changes, so the highlight follows Alt without a mouse move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:37:35 +07:00
BFlat 1b93336bdf Merge pull request #437 from bobtherobot/feat/floating-flyouts
Toolbar: tool flyouts tear off into floating toolbars
2026-10-08 13:04:27 +07:00
BFlat 9a4a632ea7 Merge pull request #436 from bobtherobot/fix/toolbar-flyouts
Toolbar: a right press or a corner press opens a tool group's flyout at once
2026-10-08 13:04:11 +07:00
bflatasticandClaude Opus 5.5 97906f2104 Toolbar: a click on the tear-off bar floats the group, window.floatTools, strips in workspaces (review of #437)
- A click on a flyout's tear-off bar, or the release of the long press
  that opened it over the bar, tears the flyout off too, as in
  Illustrator; dragging it still does.
- window.floatTools {tool, floating?} floats or puts back the group
  holding a tool, for the control channel and MCP's invoke_menu.
- Floating strips are part of user workspaces; the built-in ones float
  none, so Reset puts them back.
- Loaded preferences keep one strip per group, of known tools.
- No per-frame clone of the strips or copy of the active tool's id.
- docs/control-protocol.md, ROADMAP, Float Tool Group translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:01:40 +07:00
bflatasticandClaude Opus 5.5 b08188e5a5 Toolbar: only tool groups' buttons check for a flyout press, ROADMAP (review of #436)
A press on a single tool's button no longer schedules the repaint that
would open a flyout it doesn't have. The ROADMAP's UI list and Last
updated line name the right and corner press.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:01:25 +07:00
Michael GiesonandClaude Opus 5.5 dcc8a24f66 Toolbar: tool flyouts tear off into floating toolbars
A grab bar runs down the right side of a tool group's flyout. Dragging it
tears the flyout off: the group floats as a strip of its tools' buttons
(the active one lit, each tool's name and shortcut in its tool tip), with
the bar on its right. The same bar moves the strip, and the × at the top
of the bar puts the group back in the toolbar. While a group floats, the
presses that would open its flyout (right, long or corner press) raise
the strip and flash its border instead, so a group is never both a
flyout and a floating strip. Floating strips are kept with the UI
preferences and kept on screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:01:25 +07:00
Michael GiesonandClaude Opus 5.5 e80b3543d3 Toolbar: a right press or a corner press opens a tool group's flyout at once
A right press opens the flyout on the press instead of on the release,
including on the active tool's button, and the release leaves it open. A
primary press on the button's corner triangle opens it at once without
picking the tool; a plain click in the middle still picks it. The long
press keeps scheduling the frame where it becomes long.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:01:13 +07:00
BFlat a6fceb295e Merge pull request #435 from bobtherobot/feat/layers-panel
Layers: drag ghost, drops on the bottom-bar buttons, trash deletes again
2026-10-08 13:01:07 +07:00
bflatasticandClaude Opus 5.5 a32aed7e81 Layers: only the trash and New Layer take dropped rows (review of #435)
Illustrator documents two drop targets in the Layers panel's bottom bar:
dragging items to Create New Layer duplicates them, to the Delete icon
deletes them. Dropping rows on New Sublayer or Make/Release Clipping
Mask no longer creates a sublayer or clips the row's layer; those
buttons only act on a click, and the new test checks a drop on New
Sublayer changes nothing. The trash and New Layer share one drop loop,
and the drag ghost paints its colour bar and thumbnail with the row's
own helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:58:21 +07:00
Michael GiesonandClaude Opus 5.5 a216c36779 Layers: drag ghost, drops on the bottom-bar buttons, trash deletes again
Dragging a row shows a dimmed copy of it (colour bar, thumbnail, name)
under the pointer. A row dropped on Delete, New Layer, New Sublayer or
Make/Release Clipping Mask runs that button on the row's layer, as a
click would: Delete removes the row itself and New Layer duplicates it,
as in Illustrator.

Rows dragged onto the trash weren't deleted. The trash took the drop as
a target-circle payload first, and egui's take discards a payload of
another type, so the rows reached the second check as nothing. It now
peeks at the type before taking. The trash is also outlined while rows
or a target circle are over it, like the other drop buttons.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:58:04 +07:00
Michael GiesonandClaude Opus 5.5 80f4de1a5e Layers: Layer Options' Color list shows a chip of each colour
Each preset in the Color dropdown has a chip of its colour before its
name; Custom has none, its name aligned with the others.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:58:04 +07:00
BFlat cae68a57a1 Merge pull request #438 from storytold/fix/issue-429
Fix #429: Type on a path brackets, flip and Type on a Path Options that keep the type in place
2026-10-08 12:57:55 +07:00
bflatasticandClaude Opus 5.5 1706e84829 Fix #429: Type on a path brackets, flip and Type on a Path Options that keep the type in place
Type on a path could only be repositioned by Type on a Path Options, whose menu item sent
`{start: 0}`, so OK threw the text to the start of its path; nothing showed or dragged its
brackets. Now, as in Illustrator:

- Type on a path flows between a start and an end bracket (`TextKind::OnPath { end }`, None:
  the end of the path or once round a closed one; `TextKind::path_span`). Flipping
  (`TextObject::flip_on_path`) reverses the path keeping a closed path's first anchor and swaps
  the brackets, so the type keeps its stretch of the path.
- Type on a Path Options gains Align to Path (ascender, descender, center, baseline) and
  Spacing (glyphs spaced as if set that far above the path: closed up round the outside of
  curves, opened up round the inside), laid out by the text engine.
- `type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them in
  one undo step; with none of them it queries. The menu item opens the dialog with the selected
  type's Effect, Flip, Align to Path and Spacing (dropdowns for the choices) and leaves the
  brackets alone, so OK as opened changes nothing.
- The Selection and Direct Selection tools show selected type on a path's path and its start,
  centre and end brackets (`tools::pathtype`, with a bracket cursor): dragging the start or end
  bracket sets where the type runs, dragging the centre bracket slides it along the path and,
  across the path, flips it (Cmd/Ctrl held: it only slides). Each drag is one undo step.
- Arc-length measuring moved to `vectorcraft_geom::ArcPath` (layout, the tools and
  `text.createInPath` share it). SVG export outlines path type whose end bracket, alignment or
  spacing `<textPath>` can't express.
- docs/mcp.md documents the command and the drags; ROADMAP updated; dialog strings translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:50:25 +07:00
BFlat 696d9f619b Merge pull request #430 from bobtherobot/fix/appearance-opacity-popup
Appearance: the Opacity popup's blend list and slider work
2026-10-08 12:49:58 +07:00
BFlat 3966b0cce5 Merge pull request #426 from raytheengineer/stroke-weight-presets-per-unit
Stroke: per-unit weight presets so mm/cm/in dropdowns show clean numbers
2026-10-08 12:49:39 +07:00
bflatasticandClaude Opus 5.5 b86d19feed Appearance: the popover closes when its anchor isn't shown (review of #430)
The popover kept its open flag while its anchor was hidden (panel closed, selection changed), so
it came back open the next time the row was drawn. It now remembers the last frame it was open in
and counts as closed after a frame without it, as egui's remembered popups do. ROADMAP: the
Appearance panel's Opacity popup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:27:26 +07:00
bflatasticandClaude Opus 5.5 f155bc5514 Stroke: keep the pt ladder, fix the fallback units and per-unit decimals (review of #426)
- The pt ladder stays 0.25 … 10, 20, 30 … 100 pt (the dropdown's documented 20/30/40 pt steps).
- Feet, yards and meters returned the pt ladder converted as if it were in that unit (a 100 m
  stroke); they now get the pt ladder in points.
- weight_presets returns a fixed array (no allocation per frame); the pica ladder is stored in
  points; 0.325 in is 0.375 in (the eighths ladder).
- Fields keep three decimals in points, pixels and picas (595.276 pt) and show four in mm, cm,
  inches and larger units (8.2677 in, 0.0078 in).
- ROADMAP: the Stroke panel's per-unit presets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:27:06 +07:00
Michael GiesonandClaude Opus 5.5 e6914861f1 Appearance: the Opacity popup's blend list and slider work
egui remembers one open popup at a time, so opening the blend-mode
dropdown or the opacity slider inside the Opacity popup closed the popup,
and the dropdown or slider with it. A new widgets::popover keeps its own
open state and closes only on Escape, its anchor, or a click outside it
and the popups it opened. The Opacity popup and the Stroke popover (which
holds dropdowns too) use it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:27:06 +07:00
raytheengineerandClaude Opus 4.7 60439d88b9 Stroke: per-unit weight presets so mm/cm/in dropdowns show clean numbers
The Stroke panel weight dropdown converted one pt-based preset list into
whatever Units > Stroke was set to, which produced ugly rounded values like
0.088 mm, 0.265 mm, 10.583 mm from the pt ladder. Each unit now has its own
native ladder (observed from Illustrator's running dropdowns and typed in),
converted to pt once at use. Points, Millimeters, Centimeters, Inches,
Pixels and Picas each get Illustrator's actual values; the exotic Unit
variants (Feet & Inches, Meters, Yards, Feet) fall back to the pt ladder.

Picas round-trip to exact pt weights (1 pt, 12 pt, 60 pt …) but display as
decimal picas until Unit::number learns 0p1 / 1p3 notation; that is a
system-wide formatter change left for a separate PR.

Also bumps Unit::number precision from 3 to 4 decimals so Illustrator's
sub-eighth-inch strokes (0.0078, 0.0156, 0.0313, 0.0625 in) show their
authored values instead of rounding to 0.008 / 0.016 / 0.031 / 0.063.
Trailing zeros are still trimmed, so 1 pt stays "1".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-08 12:26:47 +07:00
BFlat 2366000e19 Merge pull request #434 from storytold/fix/issue-400
Fix #400: Scrub numeric fields by dragging their label
2026-10-08 12:25:22 +07:00
bflatasticandClaude Opus 5.5 b71d877a0a Fix #400: Scrub numeric fields by dragging their label
Dragging a numeric field's label left or right steps the field's value,
as Photoshop's scrubby labels do (beyond Illustrator): one unit every
2 points, ten with Shift, a tenth with Ctrl/Cmd (counts stay whole), in
the field's unit and precision. The panels apply each value as they
apply a typed one, so the art follows live and the caller's limits
hold (dragging back from a limit changes the value at once).

- scrub.rs: the label zone over the label noted just before a field
  (dim_label, label_row, widgets::field_label, the Character and
  Paragraph panels' icons, the Transform and Properties rotate/shear
  icons), the ↔ cursor on hover, a click on the label focuses the
  field. The text box itself never scrubs; a field without a label
  doesn't. num_field, plain_field, mixed_field (and the spin fields)
  and opt_field take part.
- Engine: undo groups (Session::begin_undo_group / end_undo_group). The
  edits made in a group, commands and committed live drags alike, are
  one undo step, journaled one by one; a cancelled group is undone and
  dropped from the journal. The app opens one while a label is dragged,
  so a scrub is one undo step whichever path the panel applies values
  by (app.run, session.execute, live_run), and Escape cancels it
  (shortcuts leave Escape to the drag, so it doesn't close a dialog or
  deselect).
- Preferences > General > Scrub Numeric Fields by Dragging
  (`scrubNumericFields`, on by default), translated in ja, es, zh-hans,
  zh-hant and pt-br.
- Tests: label drags with Shift/Ctrl, limits, counts and units, Escape,
  the preference, the cursor, click-to-focus, no scrub on the box or
  without a label, and in the app one undo step for a W drag.
- ROADMAP and docs/mcp.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:33:57 +07:00
BFlat 48cfb414ca Merge pull request #431 from storytold/fix/issue-423
Fix #423: Movable dialogs and Offset Path's Joins dropdown
2026-10-08 10:59:44 +07:00
bflatasticandClaude Opus 5.5 e04385e950 Fix #423: Movable dialogs and Offset Path's Joins dropdown
The modal dialogs were anchored to the window's centre, so none of them
could be moved. They now share one modal frame (dialogs/modal.rs): the
band from the window's top edge down to its heading moves it, as a title
bar would, with a grab cursor; it stays on screen and keeps where it was
moved (per dialog, for the session). The shared dialog frame, New
Document (and More Settings), Preferences, Keyboard Shortcuts,
Workspaces and Find Font all use it; About moves by its title bar.

The form dialogs drew every string field as a text box, so Offset
Path's Joins had to be typed. Their choice fields are now dropdowns
(Joins: Miter, Round, Bevel; Average's axis; Shear's and Reflect's
axis), the fields follow the dialog's order (Offset, Joins, Miter Limit;
Rows, Columns, Gutter) instead of alphabetical, and Offset Path has a
Preview that Cancel rolls back.

The axis labels are translated in a new "axis" catalog context: the
plain Horizontal and Vertical rows mean writing direction in Japanese.
ja, es, zh-hans and zh-hant get the axis rows (Horizontal, Vertical,
Both) and Joins, Miter, Bevel, Offset and Axis.

Headless tests drag the heading of four dialog windows (moves, a drag
below the heading doesn't, kept on screen, reopens where it was left),
open the Joins dropdown and choose Round (same result as the command
with round joins), and check the axis dropdowns and the preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:52:05 +07:00
BFlat 4c9d27b4f5 Merge pull request #428 from storytold/fix/issue-421
Fix #421: Document Color Mode can put RGB greys on the black plate
2026-10-08 10:38:20 +07:00
BFlat 45f0dcc397 Merge pull request #425 from storytold/fix/issue-419
Fix #419: PDF/.ai art past the layer scan's limit never shows a hidden layer
2026-10-08 10:38:02 +07:00
BFlat ad28cf4793 Merge pull request #424 from storytold/fix/issue-420
Fix #420: SVG import keeps clipped layers as layers with a clipping mask
2026-10-08 10:37:45 +07:00
bflatasticandClaude Opus 5.5 693e928c9c Fix #421: Document Color Mode can put RGB greys on the black plate
Switching to CMYK separates RGB greys through the CMYK profile into
four-colour greys and a rich black, as Illustrator does; only Gray
colours stay on the black plate. The command catalogue said "greys stay
greys", which read as if R = G = B colours went to K only.

- file.documentColorMode (and its alias) and document.open {colorMode}
  take grays: "profile" (default, unchanged) | "black". With "black",
  RGB greys with R = G = B convert to K only, K = their grey value (the
  inks Edit Colors > Convert to Grayscale before the switch gives);
  other colours convert as before. Swatches, tints, gradient stops and
  pattern tiles follow the same rule.
- Pattern tiles are now converted with the rest of the document (they
  kept RGB colours in a CMYK document before), as the command said.
- The command catalogue, docs/mcp.md and the ROADMAP say what happens to
  Gray colours and RGB greys.
- Tests: the profile path (four-colour greys, rich black, pattern tiles,
  Gray kept), grays: "black" over art, swatches, tints, gradients and
  patterns, document.open, and the issue's file over MCP through a PDF
  export and back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:30:32 +07:00
bflatasticandClaude Opus 5.5 ad3e326d1d Fix #419: PDF/.ai art past the layer scan's limit never shows a hidden layer
The import walks each page's content to learn which optional content
group each marked-content sequence belongs to, and stopped after 2,000,000
operators. Past that, art went to the page's "Page N" layer, visible and
printable, so a layer that was off (or didn't print) showed and printed;
an Illustrator drawing of about a million paths lost 6 of its 15 layers.

- The walk now reads up to 64 million operators a page (it reads about
  15 million a second, twice as fast as the interpreter draws them; the
  issue's 2.1-million-operator file imports in 3 s, a 10-million one in
  15 s). Marked-content tags are kept as 64-bit hashes rather than byte
  vectors, and at most 4 million a page, so the walk's memory stays
  bounded on hostile input.
- When the walk is cut short (or the interpreter and the walk otherwise
  part) and some group is off or doesn't print, art in marked content
  whose group can't be told goes to a hidden, non-printing "Page N
  (unsorted)" layer instead of the page's layer, and the warning says
  so (and that the page was too large to read, when it was). Art drawn
  past the limit inside a group already open stays in its group; art
  outside any group stays on the page's layer. Files whose groups all
  show and print keep the old fallback.

Tests (with the limit lowered): a hidden, non-printing group past the
limit lands on the hidden unsorted layer while the open group keeps its
art and the page's art stays shown; with every group shown, art goes to
the page's layer as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:30:07 +07:00
bflatasticandClaude Opus 5.5 55543da0cb Fix #420: SVG import keeps clipped layers as layers with a clipping mask
A top-level <g id> becomes a layer only when it was plain, so an Inkscape
layer (or our own export of a layer with a clipping mask) carrying a
clip-path came in as a clip group, and a file whose layers were all
clipped to one frame opened as one "Layer 1" of clip groups with the
layer names lost.

A top-level group that does nothing but clip its art (no mask, filter,
opacity or blend) is now a layer too: its clip path becomes the layer's
clipping mask (the clipping path first, as Make Clipping Mask in the
Layers panel leaves it), a clip path clipped in turn nesting a clip
group inside the layer as before. The clip building is shared with clip
groups. Top-level text that joins a clipping layer (before or after it)
isn't in its clip: the mask then becomes a clip group inside the layer
and the text sits beside it. A clip path with no shape leaves the layer
empty.

Tests: the issue's Inkscape layers (names kept, each a clipping layer,
drawn as resvg draws the SVG), a nested clip on a layer, the export ->
import round trip of clipping layers, and text joining clipping layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:29:38 +07:00
BFlat 8b2be144cc Merge pull request #427 from storytold/fix/direct-anchor-snap
Direct Selection: dragged anchors snap to points and smart guides
2026-10-08 10:26:00 +07:00
bflatasticandClaude Opus 5.5 d1536bc355 Direct Selection: dragged anchors snap to points and smart guides
Dragging direct-selected anchors never snapped: the anchor drag sent the raw
pointer delta. Now the anchor pressed on (or the pointer on a straight segment)
snaps and the other selected anchors follow by the same delta:

- Smart Guides: onto other anchors (including the anchors of its own path that
  stay put), onto path segments ("path", new segment targets gathered only for
  anchor drags, lines exactly on straight segments), else into line with
  anchors, edges, centres and artboards ("align"). The anchors that move, their
  segments and the reshaped paths' bounds are no targets, so an anchor can't
  snap to itself. Targets are gathered once when the drag begins, capped.
- Smart Guides off: Snap to Point pulls it onto anchors and ruler guides within
  its distance; Snap to Grid / Snap to Pixel apply as for drawn points.
- Shift keeps the 45-degree constraint unsnapped.

Group Selection and Direct Selection's whole-object moves now snap like the
Selection tool: its move snapping moved into a shared `MoveSnap`, and
`snap_with` into a reusable `PointSnap` that caches its targets per drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:25:07 +07:00
BFlat 2776e91dc6 Merge pull request #417 from imachus/pr/ja-line-start-bracket
Type: set an opening bracket flush at the start of a wrapped line
2026-10-08 10:17:20 +07:00
bflatasticandClaude Opus 5.5 89c9b82955 Type: clarify what mojikumi still lacks in the ROADMAP (review of #417)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:10:00 +07:00
imachusandClaude Opus 5.5 908d966579 Type: set an opening bracket flush at the start of a wrapped line
With Line-end Punctuation Half Width, an opening bracket starting a
wrapped line now loses the half-em space before it (JLREQ 3.1.5), so its
face lines up with the line's start and the line has that much more room.
A paragraph's first line keeps the bracket full width.

Also fixes upright glyphs in vertical type that lost their leading space
(consecutive opening brackets, and now the line start): they moved up the
column but also half an em across it, because the turning point moved
instead of the glyph. The tests now check the position across the line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:58:14 +07:00
BFlat ef0b61aca1 Merge pull request #422 from storytold/fix/issue-394b
Part of #394: Preferences for snapping, handles, rubber bands, pattern tiles, tints and type
2026-10-08 09:55:43 +07:00
bflatasticandClaude Opus 5.5 8e3dbd5369 Part of #394: Preferences for snapping, handles, rubber bands, pattern tiles, tints and type
Fourteen more Preferences options that were stored but read by nothing now
act, for the mouse and for agents (prefs.set, then pointer_gesture,
press_key or the commands):

- Selection & Anchor Display: Snap to Point and its distance (with Smart
  Guides off, View > Snap to Point lands a dragged selection's grabbed
  point, drawn points and reference points on anchors and ruler guides);
  Move Locked and Hidden Artwork with Artboard (artboard.move, the Artboard
  tool and Rearrange; off, hidden art now stays too); Enable Rubber Band for
  the Pen and Curvature tools; Handles (solid, hollow, large);
  Show handles when multiple anchors are selected (canvas and Direct
  Selection's handle picking); Hide Corner Widget for angles greater than.
- General: Transform Pattern Tiles is the default of a new `patterns`
  param on object.transform/move/rotate/scale/reflect/shear/transformEach
  (journalled when the art uses a pattern), and the transform dialogs get a
  Transform Patterns checkbox; Select Same Tint % (off, Select > Same
  Fill/Stroke Color takes every tint of a global or spot swatch).
- Type: new type.step {attribute, by} steps size, leading, tracking, kerning
  and baseline shift by the Size/Leading, Tracking and Baseline Shift
  increments; Increase/Decrease Font Size (Cmd+Shift+. and Cmd+Shift+,);
  the Type tool's Alt+arrows (kerning at a caret or tracking, leading,
  Shift for baseline shift, Cmd/Ctrl for five steps). Enable Missing Glyph
  Protection keeps characters a new font lacks in the font that had them
  (text.setStyle, text.setRangeStyle). Fill New Type Objects With
  Placeholder Text: type the Type tools place starts with placeholder text,
  selected (text.create / text.createInPath take `placeholder`).

Units > General, Stroke and Type were already the single source of truth
(units.rs). Auto Size New Area Type, Show Font Names in English and
Highlight Alternate Glyphs need subsystems the app doesn't have yet and
stay open with the rest of #394.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:43:12 +07:00
BFlat 3fbf14a3bf Merge pull request #416 from fahrim/mcp-skeleton-find
Skeleton reads and find for large documents
2026-10-08 09:25:59 +07:00
bflatasticandClaude Opus 5.5 9a5429a325 MCP: inspect_document slices too, stricter find filters (review of #416)
- The inspect_document tool (and the control channel's document.inspect)
  take {depth?, childLimit?}, so MCP clients get the skeleton without
  going through run_command; the tool description points at the
  drill-down path.
- document.find rejects a filter of the wrong type instead of dropping
  it (which widened the search); one opt_u64 helper parses limit, depth
  and childLimit.
- docs/mcp.md spells out find's matching rules; control-protocol.md
  and ROADMAP updated; MCP and engine tests cover the new paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:17:06 +07:00
Fahri Meral e278ccf44d MCP: skeleton reads and find for large documents
document.inspect takes the same {depth?, childLimit?} slicing as
document.node (a depth-0 read is the layer skeleton), and document.find
{name?|kind?|text?, limit?} locates nodes across the tree with their
ancestor paths and a total. docs/mcp.md documents the drill-down recipe;
the CLI stdio sweep covers both.
2026-10-08 09:17:06 +07:00
Brandon ThomasandClaude Opus 5.5 a5963b7c76 Release CI: macOS job on macos-15-xlarge
Custom macOS larger runners in a runner group do not pick up jobs; the
built-in M2 Pro label does. Only release.yml uses it, and the
actions_macos_xl budget caps the monthly spend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:07:55 -04:00
BFlat aed5bde38f Merge pull request #415 from storytold/fix/issue-414
Fix #414: select, move and delete ruler guides with the selection tools
2026-10-08 09:01:56 +07:00
bflatasticandClaude Opus 5.5 2f710d3acc Fix #414: select, move and delete ruler guides with the selection tools
Guides dragged out of the rulers could not be touched again: no tool hit-tested
them, so a click on a guide went to the art under it.

- The Selection, Direct Selection and Group Selection tools pick a ruler guide
  within the selection tolerance (over the art; Direct Selection takes an
  anchor on it first) through a shared tools::rulerguide::GuideEdit. A drag
  moves the selected guides in one undo step (Alt copies them, Shift snaps the
  dragged one to the ruler's ticks, otherwise it snaps to pixels, the grid or,
  with Smart Guides, the art's edges, centres and anchors); dropped off the
  canvas onto its ruler, the guide is deleted. Hidden or locked guides can't be
  picked; the pointer shows which way a guide moves.
- Selection::guides holds the selected guides (on their own, like slices); the
  canvas draws them in the selection colour. Delete/Backspace (edit.clear)
  delete them, the arrow keys (object.nudge) nudge them, Lock Guides and Hide
  Guides deselect them.
- Commands for agents: guide.list, guide.select {indexes, toggle?},
  guide.move {dx?, dy?, copy?} (besides {index, pos}) and guide.remove {index?};
  guide positions out of range are refused. Documented in docs/mcp.md.
- Shift while dragging a new guide out of a ruler snaps it to the ticks too
  (Unit::ruler_step, shared with the rulers' drawing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:55:51 +07:00
Brandon ThomasandClaude Opus 5.5 2bcaf08dbd Release CI: macOS job on the org macos-release runner
The standard macOS runners are shared with all CI and queue for an hour or
more. The macos-release runner (GitHub-hosted M2 Pro) is in a runner group
usable only by release.yml on the release branch, with a monthly spend cap
on the actions_macos_xl SKU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:54:52 -04:00
BFlat e1de706245 Merge pull request #412 from imachus/pr/ja-hangul-jamo
Type: no Japanese–Latin space next to Hangul compatibility or half-width jamo
2026-10-08 08:36:36 +07:00
imachusandClaude Opus 5.5 6952780c1f Type: no Japanese–Latin space next to Hangul compatibility or half-width jamo
Follow-up to #370: Hangul Compatibility Jamo (U+3130–318F, ㄱ ㅏ) and
half-width Hangul (U+FFA0–FFDC) counted as Japanese letters, so a Latin
letter next to them took the quarter em. They are left out like Hangul
syllables; the test covers both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:29:07 +07:00
BFlat f5183da3dd Merge pull request #413 from storytold/fix/issue-394
Part of #394: Preferences that act on selection, the wheel, tool tips and the grid
2026-10-08 08:28:32 +07:00
bflatasticandClaude Opus 5.5 47bed05ff4 Part of #394: Preferences that act on selection, the wheel, tool tips and the grid
Fifteen Preferences options were stored but read by nothing. They now act,
through the existing plumbing (ToolContext for the tools, the prefs read by the
canvas for the view), and reach agents through prefs.set:

- General: Show Tool Tips (off: no widget's tool tip comes due), Double Click
  To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer,
  Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways), Use Precise
  Cursors (Pen, Eyedropper, Slice and Blend pointers become a crosshair).
- Selection & Anchor Display: Tolerance (objects, anchors and handles, in
  place of the fixed 3 and 4 px), Object Selection by Path Only (compound
  paths too), Command Click to Select Objects Behind (cycles down the objects
  under the pointer; not when Cmd only borrows the selection tool), Zoom to
  Selection (Zoom In/Out centre the selection), anchor Size, Highlight anchors
  on mouse over (Direct Selection).
- Type: Number of Recent Fonts (up to 15 Recent Fonts slots).
- Guides & Grid: guide and grid Color and Style (lines or dots), Grids In Back.

The canvas's context menu, panel drops and hover highlight pick objects like
the selection tools. The control channel gets ui.wheel (with modifiers held
like ui.click) so agents can turn the wheel. The rest of #394 stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:22:24 +07:00
Brandon ThomasandClaude Opus 5.5 f7a03160d6 About: contributor and model credits compiled in (grab bag + table, self-submitted names) (#411)
* About: contributors compiled in from contributors.json (grab bag + table, sortable, name toggle)

Help > About VectorCraft gains Contributors and Models tabs next to the existing About
content, following the craftrules standard (standards/contributors.md):
contributors/contributors.json is generated by craftrules/scripts/contributors.py and baked
into the binary by crates/ui-egui/build.rs as static tables (credits.rs). Each contributor
line: @username, opt-in display/real name, PRs, commits, lines +/-/delta, binary assets +/-,
first/last commit. Sortable by every column; alphabetical ignores case and the leading @.
Labels are translated (ja, zh-hant, pt-br catalog rows).

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

* Contributors docs: craftrules is two levels up (../../craftrules)

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

* Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section

build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only
for entries the generator verified as written by that person (craftrules standards/contributors.md).

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

* Contributors: regenerate (verified people.toml entries)

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

* i18n: credits labels for catalogs added on main since this branch

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

* Contributors: regenerate after rebase onto main

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

* Contributors: regenerate (verified people.toml entries)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:22:04 -04:00
BFlat fe9f3afcb7 Merge pull request #410 from storytold/fix/issue-398
Fix #398: Linux/Wayland bitmap paste, copied files paste as art
2026-10-08 07:51:16 +07:00
BFlat c5f0859302 Merge pull request #405 from fahrim/mcp-pagination
MCP: Slice summary reads with document.node depth and childLimit
2026-10-08 07:51:00 +07:00
bflatasticandClaude Opus 5.5 4767ec7113 Fix #398: Linux/Wayland bitmap paste, copied files paste as art
Under Wayland the window is native but arboard was built without its
data-control backend, so it read the X11 clipboard through XWayland,
which only holds what X11 apps copied: Paste found nothing and reported
an empty clipboard (egui's text paste into fields uses smithay-clipboard,
which is why that worked). arboard now has `wayland-data-control`, the
same as PhotoCraft; it falls back to X11 where the compositor lacks the
protocol (GNOME's Mutter).

The reporter's clipboard also held text/uri-list: with a file copied in a
file manager, Paste would have taken the path as text before the bitmap.
Copied files now paste first, as the first one that is art (SVG, PDF,
EMF/WMF or a bitmap), told from its first 4 KiB by the engine's format
detection (`clipboard::file_flavour`): text/uri-list through arboard on
Linux and macOS (a trailing CR from CRLF lists trimmed), CF_HDROP on
Windows, read with the clipboard closed again.

Dropping files on the window can't work under Wayland yet: winit 0.30,
which eframe 0.36 runs on, has no Wayland drag and drop (winit#1881,
added in 0.31), so no dropped_files arrive. docs/development.md documents
it and the workarounds (copy and paste the files, File > Place, or run
under XWayland with `WAYLAND_DISPLAY=`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:44:23 +07:00
bflatasticandClaude Opus 5.5 526fc2e157 MCP: reject document.node slices without summary, document and sweep them (review of #405)
`depth` / `childLimit` without `summary: true` used to return the whole
full-JSON subtree silently; it is now an invalid-params error. docs/mcp.md
describes the slice parameters and childCount, the engine test covers
fractional values, unsliced reads and limits past the tree, and the CLI
stdio sweep reads a sliced group through run_command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:43:07 +07:00
Fahri Meral 81ab5cb3b0 MCP: slice summary reads with document.node depth and childLimit 2026-10-08 07:43:07 +07:00
BFlat 8221b73729 Merge pull request #397 from DWAA1660/fix/font-menu-find-font-glyphs
#358: Find Font's Replace With and the Glyphs panel use the font menu
2026-10-08 07:42:27 +07:00
BFlat c8155e9d69 Merge pull request #389 from imachus/pr/ja-charalign
Type: Character Alignment — where smaller characters line up with a line's largest
2026-10-08 07:42:07 +07:00
BFlat 52f4fb38f2 Merge pull request #390 from theironchef/fix/freehand-tool-options
Pencil, Paintbrush, Smooth, Blob Brush and Eraser: Tool Options on double-click
2026-10-08 07:41:25 +07:00
bflatasticandClaude Opus 5.5 d324138bc1 Fonts: drop the plain font dropdown now that no picker uses it (review of #397)
Find Font and the Glyphs panel were widgets::font_dropdown's last
callers; the font menu's own tests cover searching and Enter. ROADMAP:
the Glyphs panel and Find Font's Replace With use the font menu, without
its live preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:32:12 +07:00
bflatasticandClaude Opus 5.5 18cbe007b8 Character Alignment: on a character range too, with the East Asian options (review of #389)
text.setRangeStyle takes charAlign, so the panel menu works on the
characters selected with the Type tool (it routes there while text is
edited, and charAlign alone was answered with "nothing to change").
One char_align_param parses it for both commands. The menu shows with
Preferences › Type › Show East Asian Options, like Mojikumi Set and
Top-to-Top Leading. Rows in the Simplified Chinese, Spanish and
Brazilian Portuguese catalogs; the Japanese Em Box Center names the em
box. ROADMAP: the gating, the range command, and ICF top/bottom still
missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:32:11 +07:00
DWAA1660andClaude Opus 5.5 1d5e9cb239 Find Font and Glyphs: pick fonts from the font menu (#358 follow-up)
Both switch from widgets::font_dropdown to font_menu::font_menu, so they
get the same samples, favourites and filters as the Character and
Properties panels. They only pick a font: the new font_menu::picked
returns the choice and toggles stars, and never previews on the document.

Follow-up to #339 as suggested in #364.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRE8ZD3p8QgYctENhnJDz3
2026-10-08 07:32:11 +07:00
imachusandClaude Opus 5.5 178ca03eba Type: Character Alignment — where smaller characters line up with a line's largest
Characters of different sizes on one line all sat on the Roman baseline.
Japanese layout usually lines them up by their ideographic em boxes
instead (a 16 pt 第一回 next to 32 pt 雅楽 centred on it, not sitting on
its baseline). The Character panel menu now has Character Alignment:

- `CharStyle.char_align`: Roman Baseline (as before, the default), or the
  em box Top/Right, Center or Bottom/Left: a character smaller than the
  line's largest moves so that its em box top, centre or bottom lines up
  with the largest's (the em box from the font's vertical metrics, else
  0.12 em below the baseline to 0.88 em above it). Vertical type the
  same across the column (top → right). Written only when not Roman
  Baseline.
- `text.setFormat {charAlign: "romanBaseline"|"emBoxTop"|"emBoxCenter"|
  "emBoxBottom"}`; the menu shows the selection's setting; catalog rows
  in ja and zh-hant.
- Not yet: aligning on the ICF (average character face) top and bottom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:32:10 +07:00
bflatasticandClaude Opus 5.5 505f3bd7ae Freehand Tool Options: Accurate/Smooth ends on the Fidelity slider, Spanish rows (review of #390)
The Fidelity slider names its ends, Accurate and Smooth, through a
shared form::slider_ends that the Color Guide Options' Less/More now use
too. The dialog's strings get Spanish rows (complete catalog), and
Accurate rows in every catalog. ROADMAP: the Drawing tools row lists
what the freehand Tool Options still lack (Keep selected, Alt to the
Smooth tool, Blob Brush Merge Only with Selection, brush angle and
roundness).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:32:09 +07:00
theironchefandClaude Sonnet 5.5 02582f318d Fix #388: Pencil, Paintbrush, Smooth, Blob Brush and Eraser Tool Options on double-click
The five freehand tools keep options (Fidelity, fill, the two
tolerances, the brush size) that last across tool switches and are
saved with the preferences, but nothing in the window showed or changed
them: a double-click on the tool's button answered "the pencil tool has
no options", which the toolbar drops. It now opens a Tool Options dialog
with the options the tool has, and OK sets them with tool.setOption. The
dialog's strings are in the Japanese, Chinese (both scripts) and
Brazilian Portuguese catalogs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 07:32:09 +07:00
BFlat a80d9b8195 Merge pull request #409 from storytold/fix/issue-392
Fix #392: Cmd+A selects a focused field's text or the type being edited
2026-10-08 07:31:45 +07:00
BFlat 2a434190f2 Merge pull request #408 from storytold/fix/issue-396
Fix #396: SVG type opens at the size it draws at
2026-10-08 07:31:29 +07:00
BFlat d036a6d613 Merge pull request #403 from storytold/fix/issue-369
Fix #369: restart the web build's graphics after a lost device or context
2026-10-08 07:31:13 +07:00
bflatasticandClaude Opus 5.5 0916534db9 Fix #392: Cmd+A selects a focused field's text or the type being edited
Select All (Cmd/Ctrl+A) now follows the keyboard, as in Illustrator:

- macOS menu bar: the system menu takes Cmd+A/X/C/V before the window
  sees them, so a focused panel, dialog or search field got the document
  command. `menus::invoke_from_system_menu` (used by `NativeMenu::poll`)
  hands Select All, Cut, Copy and Paste to a focused text field as the
  events its keys would send; clicks on those menu items follow the same
  rule. Other commands, and these with no field focused, run as before.
- Type tool: `select.all` itself selects all of the text being edited
  while the Type tool edits (returns {editing, start, end}), so the menu,
  the shortcut, the control channel and headless MCP (`press_key`) agree.
  The UI's own Select All interception and the Type tool's hard-wired
  Cmd+A are gone; Cmd+A reaches it through the Select All shortcut.
- Windows, Linux and the web already left Cmd+A to a focused field; now
  covered by headless app-frame tests (field, Type tool, canvas).

Docs: docs/mcp.md (Select All while editing type), ROADMAP. Not yet:
Select All across threaded frames (one frame's text is selected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:18:52 +07:00
bflatasticandClaude Opus 5.5 ffed92aba5 Fix #396: SVG type opens at the size it draws at
SVG import kept a <text>'s nominal font-size and left the scale of the
transforms and viewBox above it in the type's transform, so the Character
panel showed one size and the canvas drew another (7 pt under scale(3)
drew at 21 pt).

The importer now moves that scale into the type: the scale across the
baseline (the mean scale for type on a path) multiplies the size,
leading, baseline shift, character stroke weights and dashes and the
text path, and the transform keeps only the rest (a stretch along the
baseline, a skew, a rotation or a reflection). The type draws exactly as
before. Paints resolve in the grown text space, non-scaling strokes and
the stroke under the characters are worked out before the fold, the
factor is rounded to 6 significant digits (usvg's transforms are single
precision) and never pushes sizes past the Character panel's
0.1–1296 pt (the rest stays in the transform).

Tests: a group's, the text's own and a viewBox scale; rotated and scaled
text equal to the same text written at twice the size; stretch and skew;
type on a scaled path; the size limits; gradients on scaled text; SVG
export → import of scaled type (stable over two round trips).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:18:34 +07:00
bflatasticandClaude Opus 5.5 649656b092 Fix #369: restart the web build's graphics after a lost device or context
When the browser dropped the page's WebGPU device or WebGL context, the
editor kept running but nothing it drew reached the screen: Chrome and
WebKit showed a frozen, stale canvas and Firefox a blank page, while
edits and Save went on.

- ui-egui: new `graphics` module. `GraphicsLoss` carries a loss report
  from any thread (wgpu's device-lost callback, the canvas's
  `webglcontextlost` event) to the UI thread. `VectorcraftApp::
  graphics_lost` writes Data Recovery copies of the modified documents at
  once. The app now notices when the host hands it a new egui context and
  uploads everything again (fonts and theme, the canvas, place cursor
  thumbnails); every per-thread texture cache (layer, symbol, swatch,
  style, asset and glyph thumbnails, previews, font samples, Navigator,
  Flattener Preview) is a `TexCache` that empties for a new context.
  Without this, the moved app drew stale or wrong textures and egui
  panicked on its missing font families.
- web: the shell watches both loss signals; on a loss it puts a fresh
  canvas in place of the dead one and starts the same `WebRunner` again,
  handing over the same app (documents, undo, panels), with services made
  for the new context and a status-bar note. If no new graphics can be had
  (or after three losses) it removes the stale canvas, lets go of the
  recovery copies and says to reload.
- engine: `recovery::leave` lets go of a session's area but keeps its
  copies, so the reloaded page offers them at once instead of after the
  heartbeat goes stale.
- desktop: eframe can't give a window a new device, so a lost device
  (driver reset) is logged, the copies are written and the status bar says
  to save and restart; closing then skips the Save Changes question it
  can't show.
- Tests: loss reporting across threads, `TexCache` emptying, the app
  uploading the canvas, a cached preview, theme and fonts again in a new
  context (fails without the fix), copies written on loss, and
  `recovery::leave` offering copies at once with both lock and heartbeat
  stores. docs/development.md (web section) and ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:18:32 +07:00
BFlat 04643e1109 Merge pull request #404 from storytold/fix/issue-381
Fix #381: round single corners with Direct Selection (per-corner Live Corners)
2026-10-08 07:12:24 +07:00
BFlat 43c0df66a3 Merge pull request #401 from storytold/fix/issue-372
Part of #372: PDF/.ai import keeps strokes live and hidden and nested layers
2026-10-08 07:12:04 +07:00
BFlat a9ae2fd513 Merge pull request #402 from storytold/fix/issue-374
Fix #374: Shape Builder cuts regions with open paths and lines
2026-10-08 07:11:49 +07:00
BFlat 0a0357143d Merge pull request #407 from storytold/fix/batch-rollback-layer-rows
Engine: a failed batch gives back the highlighted Layers panel rows
2026-10-08 07:11:31 +07:00
bflatasticandClaude Opus 5.5 b05c14f8eb Fix #381: round single corners with Direct Selection (per-corner Live Corners)
A live rectangle stored four corner radii but drew them all with the largest,
and the corner widgets always rounded all four corners, so one corner could not
be rounded on its own.

- geom: rectangle_with_corners draws each corner with its own radius and kind
  (Round, Inverted Round, Chamfer); a sharp corner is one anchor, a cut one two.
  Uniform corners keep the exact rounded-rectangle anchors.
- doc: LiveShape::Rectangle gains `kinds` (saved only when a corner isn't round;
  older files read as round), plus helpers mapping anchors to corners.
- engine: object.setLiveShape takes `corners` and `kind`; without `corners` it
  edits the Direct-Selected corners, else all four, and keeps the selected
  corners selected as their anchors change.
- tools: with Direct Selection, only the selected corners show widgets and a
  drag rounds just those; Alt-click cycles the corner kind; double-click opens
  the new Corners dialog (also `ui.corners`).
- ui: Properties/Transform radius fields show and set the selected corners;
  CSS export keeps per-corner radii and skips cut-in corners.
- docs: mcp.md (Live Corners), control-protocol.md (Corners dialog), ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:56:32 +07:00
bflatasticandClaude Opus 5.5 3585ef654c Part of #372: PDF/.ai import keeps strokes live and hidden and nested layers
An object Illustrator writes as a fill of its outline left open and then a
stroke of it closed imported as two objects: a filled path and a separate
stroke-only path. A fill and a stroke of the same outline (equal once every
subpath is closed) now become one path with the fill and a live stroke
(width, cap, join, miter limit, dash, paint), taking the stroke's outline.

Layers: a group whose view state is off (/Usage /View /ViewState /OFF, with or
without an /AS view event) comes in as a hidden layer with its art, as groups
off in the default configuration already did. The configuration's /Order
nesting (inline or as its own object, labels skipped) makes sublayers, which
take their top-level layer's colour; nesting depth, entries and groups are
capped. Splitting a page's art by group is no longer quadratic, so thousands
of layers import quickly.

The 6D art pen (rotation-aware brushes) is a separate feature, now on the
ROADMAP gap list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:55:51 +07:00
bflatasticandClaude Opus 5.5 28fc79d57a Fix #374: Shape Builder cuts regions with open paths and lines
The Shape Builder only used the filled areas of the selected paths, so a
stroke-only line or open path across a shape did nothing. Like the
reference app's Shape Builder, open paths now act as cutting edges:

- pathops::shape_builder: with an open path in the selection, the
  regions are the Live Paint planar map's faces (every path an edge), so a
  line across a shape splits it and lines can enclose a region of their
  own; it also returns each open path cut where other paths meet it. Closed
  shapes alone, more than SHAPE_BUILDER_MAX_SEGMENTS segments, or a map
  that can't be built fall back to the filled-area arrangement.
- Filled open paths count as closed (an invisible closing edge) where that
  encloses an area; unfilled open paths and straight lines stay edges.
- shapeBuilder.merge: merging or deleting regions on both sides of a line
  piece removes that piece (the line is split, its other pieces kept); an
  area only lines enclose takes the current fill and the front-most
  object's look. Alt (erase) within `tolerance` of a line piece deletes it.
  Returns `lines` (pieces deleted).
- shapeBuilder.regions also lists the line pieces.
- The tool shares BuilderMap/Touched with the command, highlights the line
  piece under the pointer while Alt is held and passes its tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:55:40 +07:00
bflatasticandClaude Opus 5.5 1d04ee0e84 Engine: a failed batch gives back the highlighted Layers panel rows
`command.batch` rolls a failed batch back through each document's interaction, which restored the
art, the selection, the current layer and isolation but not the highlighted Layers panel rows. A
batch whose `layer.setCurrent` step ran before a later step failed left that layer's row
highlighted, so a following Collect in New Layer collected the layer instead of the selected art.
The failed batch isn't journaled, so replaying the journal gave a different document: the
`journal_replay_reproduces_document` property test failed whenever it drew that sequence.

The interaction now keeps the highlighted rows too, which also puts them back when a drag is
cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:55:39 +07:00
BFlat 72fc1ac0e1 Merge pull request #399 from storytold/fix/issue-367
Fix #367: keep a paused browser tab's recovery copies
2026-10-08 06:38:33 +07:00
BFlat d8a2d4cd81 Merge pull request #385 from imachus/pr/ja-leading
Type: Top-to-Top Leading — leading measured from em box top to em box top
2026-10-08 06:38:12 +07:00
BFlat c70f69253b Merge pull request #386 from theironchef/fix/selection-tools-open-move-dialog
Enter opens the tool's dialog; the selection tools open Move
2026-10-08 06:37:53 +07:00
bflatasticandClaude Opus 5.5 048cbc51b1 Fix #367: keep a paused browser tab's recovery copies
A heartbeat alone can't tell a crashed tab from one whose timers stopped in
the background, so another tab could discard (or restore) the copies of a
still-live document once its heartbeat went stale.

- Recovery stores without locks can now announce an area for as long as the
  app runs (RecoveryStore::announce/announced). The web shell implements it
  with the browser's Web Locks (navigator.locks through js_sys::Reflect): a
  tab holds a lock named after its area, which the browser releases only when
  the tab is gone. Lock snapshots are refreshed every 10 s (once before the
  first frame); a snapshot over a minute old counts every area as held.
- An area is gone only when no announcement holds it and its heartbeat is
  stale. Where the store can't tell (no Web Locks), the owner repairs: each
  heartbeat (also its first frame after a pause) and each recovery save write
  again the copies missing from its area, and a heartbeat newer than the one
  another tab wrote when taking the area over marks it running again.
- MemoryStore::announcing models Web Locks in tests; engine and UI tests cover
  the issue's sequence. Native folder locks are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:25:39 +07:00
bflatasticandClaude Opus 5.5 5865e4cb51 Type: Top-to-Top Leading follow-ups (review of #385)
- The em box top that Top-to-Top Leading asks of every glyph's line metrics parsed the font's
  cmap and vertical metrics per glyph on every layout: each face now reads it once.
- The Paragraph panel menu shows Top-to-Top / Bottom-to-Bottom Leading with the East Asian
  options (Preferences › Type), as the Mojikumi Set does, and Reset Panel goes back to
  Bottom-to-Bottom Leading, the default.
- Saved as `leading_model`, like the other paragraph attributes in files.
- Simplified Chinese (landed since) translates the two menu items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:25:18 +07:00
imachusandClaude Opus 5.5 46dc8b1bde Type: Top-to-Top Leading — leading measured from em box top to em box top
Japanese layout measures leading from the top of one line's ideographic
em box to the next (the right side, in vertical type), not from baseline
to baseline. The Paragraph panel menu now offers both:

- `ParaStyle.leading_model`: RomanBaseline (as before, the default) or
  EmBoxTop. With EmBoxTop a line's leading is the space below it — the
  next line's em box top is this line's plus its leading, and the next
  baseline hangs from there by that line's tallest em box — and area
  type's first line touches the top of the frame (its em box top on the
  frame's top, at least Area Type Options' minimum). With baseline
  leading a line's leading stays the space above it.
- Lines of one size are exactly as far apart either way; the models
  differ for mixed sizes and for the first line of area type (fonts
  whose ascent isn't the em box top: Hiragino's nearly is).
- The em box top comes from the font's vertical metrics (#343), else
  0.88 em. Written only when EmBoxTop.
- `text.setFormat {leadingModel: "romanBaseline"|"emBoxTop"}`; menu items
  Top-to-Top Leading / Bottom-to-Bottom Leading (checked as set); catalog
  rows in ja and zh-hant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:24:41 +07:00
theironchefandClaude Sonnet 5.5 5883b6c1fd Enter opens the tool's dialog; the selection tools open Move
The Rotate, Scale, Reflect and Shear tools answer Enter with their
dialog, but the shortcut handler only applied what a tool asks of the
UI for the keys it claims, so the request was dropped and Enter did
nothing. It now applies it for Enter, and consumes the key when a
dialog opened, since a dialog takes Enter as its OK in the same frame.

The Selection, Direct Selection and Group Selection tools open the
Move dialog the same way, on Enter and on a double-click on their
toolbar button (`tool.options`), as Object > Transform > Move does.
With nothing selected neither opens anything.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 06:24:24 +07:00
BFlat 24bea90921 Merge pull request #395 from storytold/fix/issue-319
Fix #319: exports warn when type in a missing font is written in the fallback font
2026-10-08 06:23:26 +07:00
BFlat 0169781847 Merge pull request #393 from storytold/fix/issue-320
Fix #320: CMYK images stay CMYK from PDF import to PDF export
2026-10-08 06:22:59 +07:00
BFlat 50e601703e Merge pull request #391 from storytold/fix/issue-334
Fix #334: .ai import leaves out the white page under the layers
2026-10-08 06:22:41 +07:00
BFlat 53a1115a9a Merge pull request #387 from storytold/fix/issue-362
Fix #362: Spanish interface (es)
2026-10-08 06:22:15 +07:00
bflatasticandClaude Opus 5.5 4b6c3d1e2c Fix #319: exports warn when type in a missing font is written in the fallback font
Opening a PDF whose fonts aren't installed keeps the text live in the fallback font (as
Illustrator substitutes and highlights missing fonts), and opening said so, but every export
then wrote the fallback font silently ("warnings": []), so a headless convert re-set the type
without a word.

- Exports that draw type now say so in their warnings, naming the missing families and the
  fallback font: PDF (outlined or real text, also .ai and Save As), EPS, EMF/WMF, raster images
  and SVG with outlined or embedded fonts. Live SVG text, DXF, text and native files name the
  font itself and don't warn. Replacing the font (Find Font) clears it.
- The fonts a document uses (text.fonts' scope, Package's font list) come from one helper.
- textAs: outlines already keeps the glyphs the PDF draws (its embedded font program, installed
  or not): now covered by a test with an embedded serif font that isn't installed, checked
  against the installed font's and the fallback's outlines.
- docs/mcp.md and ROADMAP describe the warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:04:29 +07:00
bflatasticandClaude Opus 5.5 ab62b5f8e8 Fix #320: CMYK images stay CMYK from PDF import to PDF export
A PDF's DeviceCMYK image opened as RGB (the interpreter's pixels as a PNG),
so every export wrote it as DeviceRGB or ICC RGB, silently. Images now keep
their CMYK samples, as CMYK colours already did:

- Model: CMYK images are blobs holding CMYK samples, a four-component JPEG
  (Adobe's inverted samples) or a Deflate CMYK TIFF. vectorcraft-doc's cmyk
  module reads their ink amounts (ImageBlob::is_cmyk, ::cmyk, capped at 64
  megapixels) and writes the TIFF (ImageBlob::cmyk_tiff).
- PDF import: DeviceCMYK, CalCMYK and four-component ICC images keep their
  samples. A JPEG with Decode [1 0 1 0 1 0 1 0] is kept byte for byte; any
  other is decoded (8 or 16 bits, its Decode array applied) and stored as a
  CMYK TIFF. Masked CMYK images and 1/2/4-bit ones open in RGB with a warning.
  Placing a CMYK TIFF keeps it CMYK too.
- PDF export: CMYK images are written in CMYK (DeviceCMYK, or ICC-based with
  the CMYK profile when colours are tagged: PDF/X-3, PDF/X-4, PDF/A). A CMYK
  JPEG neither resampled nor recompressed passes through unchanged; others are
  resampled in CMYK and compressed as ZIP or as a CMYK JPEG (jpeg-encoder,
  which also replaces the ZIP fallback and its warning for images converted to
  CMYK). Output conversion treats them like CMYK colours: Preserve Numbers and
  PDF/X-1a keep their numbers, Convert to Destination converts them to another
  CMYK profile or to RGB through a 17^4 lookup table (cms::CmykLut, now also
  behind the renderer's ink table). A CMYK image that can't be decoded is
  reported in the warnings.
- Canvas: CMYK images show through the working CMYK profile, as CMYK fills
  do, and paint their own inks in ink planes (CMYK raster exports, CMYK
  blending). SVG export writes TIFF images as PNG.

Illustrator's Output › No Conversion keeps colour data as it is
(https://helpx.adobe.com/illustrator/using/pdf-options.html).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:04:07 +07:00
bflatasticandClaude Opus 5.5 a18e671817 Fix #334: .ai import leaves out the white page under the layers
A PDF-compatible .ai paints the editor's page (an opaque white rectangle the
size of the page) before its first optional-content layer and outside every
layer. It came in as art: an extra "Page 1" layer with a page-sized white path
behind the artwork, which hid white art and gave SVG/PNG/PDF exports an
opaque background.

For pages with the editor's private data (the way the importer already tells
a .ai from a PDF), the first art outside every layer, painted before any
layer, is dropped when it is a plain fill-only rectangle in an opaque,
non-spot white, with normal blending, equal to the crop box (0.5 pt
tolerance). Plain PDFs keep such a rectangle as art (as the reference app
does when opening a PDF), and so does anything else: grey, smaller or larger
rectangles, strokes, translucency, white inks, fills over the layers.

The white-cover test the soft-mask reader used is now a shared helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:03:45 +07:00
bflatasticandClaude Opus 5.5 a3fec5486d Fix #362: Spanish interface (es)
Adds a Spanish catalog (crates/ui-egui/src/i18n/es.tsv) covering the whole
interface like Japanese and Traditional Chinese: every menu string, every
tl! literal, the hint-bar fragments, plurals and the runtime strings the
other complete catalogs carry. It is written from the meaning of the
English labels in neutral, international Spanish (Latin American usage)
with the vocabulary users of vector illustration software know (Mesa de
trabajo, Muestras, Trazo, Relleno, Trazado, Pluma, Selección directa,
Buscatrazos, Motivo, Preajuste); no other application's localisation was
copied. Like Japanese it keeps the product, OpenType, the built-in
workspace names and the perspective grid preset names in English.

The language is registered with complete_menus (so the completeness
tests enforce it) and the one/other plural rule; every es-* system locale
(es-ES, es-MX, es-AR, es-419 ...) resolves to it.

Tests: es locale tags, Spanish vocabulary and plurals, Spanish letters in
the bundled UI fonts, and a new check that catalogs written with spaces
keep the leading/trailing spaces of joined fragments. ROADMAP, README and
docs/development.md list the language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:03:37 +07:00
BFlat dc947d30c1 Merge pull request #379 from njmueller/compat/windows7-x64
Add opt-in Windows 7 SP1 x64 compatibility build
2026-10-08 06:02:48 +07:00
BFlat 49567e9594 Merge pull request #377 from theironchef/fix/transform-tools-alt-drag-copies
Rotate, Reflect, Scale and Shear tools: Alt-drag transforms a copy
2026-10-08 06:02:16 +07:00
BFlat 831a5e5c96 Merge pull request #366 from theironchef/fix/tab-keeps-shortcuts-working
Tab no longer leaves the keyboard focus on a hidden field
2026-10-08 06:01:47 +07:00
BFlat 8e80d6bcc9 Merge pull request #357 from theironchef/fix/num-field-arrow-keys
Panel number fields: Up/Down arrows step the value (Shift ×10, Ctrl ÷10)
2026-10-08 06:01:17 +07:00
BFlat 0bf7422d9c Merge pull request #363 from theironchef/fix/direct-segment-drag
Direct Selection: dragging a segment bends it (curved) or moves its anchors (straight)
2026-10-08 05:59:49 +07:00
bflatasticandClaude Opus 5.5 caeecf4b26 M14: Windows 7 build: patch windows-link for that build only, manual CI
- Drop the workspace [patch.crates-io] and restore windows-link's crates.io
  source and checksum in Cargo.lock, so every normal build keeps the
  checksum-verified crate. windows7.ps1 now applies vendor/windows-link with
  --config, checks that only windows-link's lock entry moved, builds and tests
  with --locked, checks the patched crate is the one in use, and restores
  Cargo.lock afterwards.
- windows7.yml runs on demand and weekly instead of on every PR, pins every
  action by commit SHA, and leaves the dependency check and host tests to the
  script (-Test) so the Cargo arguments live in one place.
- ROADMAP, README, NOTICE and docs: experimental, unsupported, not verified by
  maintainers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:37:06 +07:00
njmueller 58e3916e03 M14: add Windows 7 SP1 x64 compatibility build 2026-10-08 05:36:44 +07:00
bflatasticandClaude Opus 5.5 3013bbc94f ROADMAP: Alt-drag with the transform tools copies (review of #377)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:35:55 +07:00
theironchefandClaude Sonnet 5.5 12b56818b6 Alt-dragging with Rotate, Reflect, Scale or Shear transforms a copy
With Alt down at the press, the four transform tools opened their
dialog at once, so an Alt-drag did nothing: no transform and no copy.
They now decide on release: a click made with Alt still sets the
reference point and opens the dialog, and a drag made with Alt
transforms a copy about the reference point, as it does in
Illustrator.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 05:35:55 +07:00
bflatasticandClaude Opus 5.5 aad734e264 ROADMAP: Tab leaves the keyboard to the shortcuts (review of #366)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:35:54 +07:00
bflatasticandClaude Opus 5.5 256e938fff Number fields: arrows step plain fields too, keep the value selected; ROADMAP (review of #357)
The Up/Down stepping of #357 now serves every numeric field through one
helper: num_field (lengths, in their unit) and plain/mixed fields
(degrees, percent, counts, at their precision: a count ignores the
Ctrl/Cmd tenth), as in Illustrator's panels and dialogs. After a step
the new value is selected, so typing replaces it. The select-all code
the dialog focus and focus/double-click already duplicated becomes one
helper, and the tests move to tests_numfields.rs with its field harness
(which can now run with the window unfocused). ROADMAP.md notes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:35:54 +07:00
bflatasticandClaude Opus 5.5 acc708ec83 Direct Selection: Alt on a segment still picks the whole path; ROADMAP (review of #363)
With Alt held, a press on a path's stroke selects the whole path and
an Alt-drag copies it, as before #363 and as Group Selection does in
Illustrator; only a plain press on a segment selects its anchors and
reshapes it. Records the segment drag in ROADMAP.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:35:54 +07:00
theironchefandClaude Sonnet 5.5 746a271aeb Tab no longer leaves the keyboard focus on a hidden field
Tab is the Panels shortcut (show/hide all panels), but egui's own
focus navigation read the same Tab press at the start of the frame and
moved the keyboard focus on to a field. The shortcut handler returns
early while a field has the keyboard, so after one Tab a second Tab,
tool letters and Ctrl chords did nothing until a click or Esc, and in
the Type tool the letter typed after a Tab was lost. When no field has
the keyboard, the handler now cancels the focus move for that frame;
in dialogs, the command palette and panel fields, Tab still moves
between fields.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 05:35:54 +07:00
theironchefandClaude Opus 5.5 85dafc78d7 Fix #352: Up/Down arrows step the value of panel number fields
Panel number fields (num_field: X, Y, W, H, corner radius, stroke
weight, font size...) only parsed their text on Enter or focus loss,
so the arrow keys did nothing. With the field focused, Up/Down now
step the value by one of the field's unit (Shift: ten, Ctrl/Cmd: a
tenth), from the value shown, and apply it at once, as in the
reference app.

The field's focus is read from egui's memory, as its highlight and its
typing do: Response::has_focus also needs the window to report
keyboard focus, which the running app's window doesn't always do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 05:35:54 +07:00
theironchefandClaude Sonnet 5.5 2b56a91ce2 Fix #361: Direct Selection: dragging a segment bends or stretches it
Any hit on a path's stroke took the "select the whole path" branch, so
dragging a segment moved the whole path. Now a press on the stroke of
a path that isn't selected as a whole selects the segment's two
anchors (unless they already are), and the drag bends the segment
with path.reshapeSegment when it's curved, else moves the selected
anchors (a straight edge moves, the rest stays), as in the reference
app. The fill, Shift-click and a path selected as a whole keep their
behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 05:35:54 +07:00
BFlat ce12b4b0f5 Merge pull request #378 from storytold/fix/issue-295
Fix #295, fix #354: open files from the macOS Finder and Dock
2026-10-08 05:32:55 +07:00
BFlat b5a7994df0 Merge pull request #375 from storytold/fix/issue-359
Fix #359: web drops land in the document they were dropped on
2026-10-08 05:32:40 +07:00
BFlat 5d49a2f4bf Merge pull request #248 from coygeek/docs/star-history
docs: add star history chart to README
2026-10-08 05:32:15 +07:00
BFlat 048db2d230 Merge pull request #303 from Pengjingyu1992/pr/simplified-chinese-cjk
M7: Simplified Chinese, horizontal CJK composition and compact UI fixes
2026-10-08 05:31:53 +07:00
BFlat 80307f11da Merge pull request #300 from daniel-efrat/codex/automatic-rtl-text
Add automatic Hebrew/Arabic RTL text and paragraph alignment
2026-10-08 05:31:25 +07:00
BFlat 038f344deb Merge pull request #383 from storytold/pr/review-followups-2026-10-08
Review follow-ups for #343, #350, #317, #339, #328, #318, #331
2026-10-08 05:31:04 +07:00
bflatasticandClaude Opus 5.5 b2c6764472 Fix #295: open files from the macOS Finder and Dock
Finder, Open With, a drop on the Dock icon and `open -a` failed with "VectorCraft cannot open
files in the PDF document format":

- The release Info.plist declared no document types, so Launch Services refused the files before
  the app saw them. Info.plist.in now declares every format File › Open reads (the engine's
  OPEN_EXTS; a test keeps them in step): VectorCraft owns its own documents and templates and is an
  alternate for the others, so it takes over no other app's files. `cargo xtask bundle` now fills
  in the same template instead of its own, shorter plist.
- macOS delivers such files as open-document events, not arguments. winit 0.30's application
  delegate has no `application:openURLs:`, so AppKit fell back to NSDocumentController, which
  refuses every file of a non-NSDocument app (the error in both issues). The new
  `open_documents` module adds that method to the delegate's class when the app is about to finish
  launching (before AppKit delivers launch files), queues the paths and wakes the UI, which opens
  them with `io::open_path` like command-line files; errors go to the status bar too.

New direct macOS dependencies `objc2-foundation` 0.3 and `block2` 0.6 were already in Cargo.lock
(through winit, muda and rfd). The module allows `unsafe_code` for the Objective-C runtime call.

Fixes #354

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:28:50 +07:00
bflatasticandClaude Opus 5.5 accfec7146 Fix #359: web drops land in the document they were dropped on
The web reads dropped files asynchronously, and the place inbox only kept
the drop point, so a file dropped on document A was placed into whatever
document was active when its bytes arrived. A drop on a canvas now carries
place::DropAt (the document's uid, the point, embed), and drop_files makes
that document active again before placing; when it has closed meanwhile,
nothing is placed and the status bar says so. The native drop path is
synchronous and goes through the same code, so it is unchanged in effect.

Test: a drop pending while another document opens lands in the first one
at the drop point; one whose document closes is not placed anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:28:50 +07:00
bflatasticandClaude Opus 5.5 7485dac6e8 Docs: the star history chart sits before License and credits (review of #248)
The ArtCraft footer stays the README's last block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:28:50 +07:00
bflatasticandClaude Opus 5.5 d218e60168 i18n: Simplified Chinese rows for the strings that landed alongside the catalog
Simplified Chinese is a complete catalog, so the strings added the same day need rows: Delete
Anchor Points, Edit Selection and the 25 Saved Selection slots, Paragraph Direction and the
Mojikumi Set. Remove Anchor Points reads 移除锚点, apart from Delete Anchor Points (删除锚点), as in
Traditional Chinese.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:28:50 +07:00
Coy Geek bc4063f701 Docs: a star history chart in the README
Add a linked Star History chart at the bottom of the README so readers can see the repository star count over time. Use the date view with a top-left legend.
2026-10-08 05:28:50 +07:00
bflatasticandClaude Opus 5.5 27e8c695c1 i18n: Simplified Chinese reads as mainland Chinese, with rows for the strings added since (review of #303)
- Taiwan vocabulary left over from the zh-hant rows #303 started from is
  replaced with the mainland's, about 560 rows: 档案 → 文件, a Taiwan 文件
  (document) → 文档, 工作区域 → 画板, 笔画 → 描边, 色票 → 色板, 遮色片 → 蒙版,
  渐层 → 渐变, 偏好设定 → 首选项, 快速键 → 快捷键, 按一下 → 单击, 描述档 → 配置文件,
  品质 → 质量, 汇出/汇入 → 导出/导入, 连结 → 链接, 点阵 → 栅格, a row 列 → 行,
  「」 → “”, half-width parentheses; tool names that collided or were wrong
  are fixed (Selection Tool 选择工具, Scale Tool 比例缩放工具, Warp 变形,
  Puppet Warp 操控变形, Divide 分割, Exclude 差集, the 直排 type tools).
- 42 rows for strings that landed after #303 (Layers panel, graphics
  processor preference, panel collapse, the font menu's filters).
- A catalog header documenting the format and the vocabulary, and a test
  that zh-hans has no Traditional characters or Taiwan terms.

rows were the better base (hand-adjusted mainland terms in the menus and
dialogs), so no rows were taken from #316.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:28:49 +07:00
Pengjingyu1992 1e4db64362 i18n: Simplified Chinese (zh-hans) interface catalog
The zh-hans catalog, registered as 简体中文: zh-CN, zh-SG, zh-Hans-* and
a bare zh locale resolve to it (tested), and it claims complete menus.
NOTICE lists the craft-fonts faces of the current manifest (Noto Sans CJK
SC and Noto Sans Arabic were missing).

Landed from #303 (catalog, registration, locale tests and the NOTICE
line only). Its horizontal CJK composition, UI font fallback by weight,
dock, New Document, Brushes panel and preference-migration changes are
left for separate pull requests.
2026-10-08 05:28:49 +07:00
bflatasticandClaude Opus 5.5 3121bba275 Type: files with Auto alignment open in older builds (review of #300)
New type's Auto alignment is saved as the alignment it has in the
paragraph direction (Right for right to left, else Left) plus an
optional `justify_auto` flag, so builds without Auto parse the file
and lay it out the same; this build reads Auto back. Paragraph styles
keep Auto as one `justify` attribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:25:03 +07:00
bflatasticandClaude Opus 5.5 0687d2d127 Type: Paragraph Direction instead of an Automatic alignment toggle; bidi only where needed (review of #300)
Illustrator sets a paragraph's direction (Left-to-Right / Right-to-Left
Paragraph Direction in the Paragraph panel, with the Middle Eastern
options) and keeps its alignment buttons physical; it has no automatic
alignment toggle. So:

- ParaStyle.direction (None: from each paragraph's first strong
  character, as before; written only when set), the Paragraph panel's two
  direction buttons with Show Indic Options (original icons), and
  text.setFormat {direction: "auto"|"leftToRight"|"rightToLeft"}. The
  layout uses it as the base direction.
- The Automatic alignment check box is gone. Justify::Auto stays as the
  start of the paragraph's direction, set on new type only (text.create,
  the Type tools, with the mojikumi default in one new_type_para()):
  TextObject::point is Left again, so importers (PDF, SVG, DXF, EMF) keep
  their own alignment. The alignment buttons show what Auto resolves to.
- Bidi only where there is something right to left: plain paragraphs skip
  the algorithm (no per-layout allocation), it runs once per paragraph
  (shaping takes the paragraph's levels instead of recomputing them; path
  type computed it four times), and the shaper keeps Han, Hiragana,
  Katakana and Bopomofo in one segment (splitting at every change of
  script cost Japanese text a shaper call per kana-kanji switch).
- Vertical type keeps its logical order, so its glyphs and lines are not
  marked right to left (the caret no longer runs backwards down a column).
- PDF import puts Hebrew and Arabic, which PDFs (and VectorCraft's own
  export) draw in visual order, back in logical order with the direction
  that shows it as drawn; with bidi layout it came back mirrored.
- SVG import pins the direction SVG implies (left to right unless
  direction: rtl); SVG export also outlines right-to-left paragraphs.
- Docs (development.md), ROADMAP (folded into the Type entry, Type core
  row) and README updated; catalog rows for the new labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:25:01 +07:00
bflatasticandClaude Opus 5.5 7c3030ff52 EPS import: type layout counts against the op budget, hostile executable-file tests (review of #331)
Laying out type (show, stringwidth, cshow) costs about 300 operations plus
64 a character, measured, and now counts that much against MAX_OPS: a
300-byte file running cshow over a 4 MB string took 60 s, and
{ (a) stringwidth pop pop } loop ran for over 5 minutes (already the case on
main); both now stop with "the program runs too long" within seconds.
Tests: executable filters run inside themselves stop at the nesting limit
(directly, mutually and under stopped), currentfile cvx exec runs nothing
twice, cshow over a long string is refused; the hostile PostScript fuzz
tokens gain self-recursive filter execution, currentfile filters, cvlit and
cshow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:24:44 +07:00
Daniel Efrat edc7c931dc Type: Hebrew and Arabic text laid out right to left (bidi), aligned by its direction
- Unicode bidi (unicode-bidi, unicode-script, unicode-general-category):
  each paragraph's direction from its first strong character, Hebrew and
  Arabic shaped right to left, embedded Latin and numbers keep theirs,
  lines wrapped in logical order and reordered per line by whole
  clusters; source text, undo and style ranges stay logical.
- Caret, hit testing, selection and the arrow keys follow the visual
  order.
- New text aligns automatically: right-to-left paragraphs right, others
  left (Justify::Auto, text.setStyle {justify: "auto"}); the Paragraph
  panel highlights the resolved alignment and has an Automatic alignment
  toggle. Explicit alignments, and older documents', are kept.
- SVG export outlines bidirectional text (with a warning) until it can
  be written as directional live text.
2026-10-08 05:24:44 +07:00
bflatasticandClaude Opus 5.5 249b34fc7c Select: saved selections checked when a file opens, names capped, docs (review of #318)
A file's saved selections are untrusted: on load at most 25 are kept, names
are trimmed, capped at 255 characters and unique, and ids the document
doesn't have are dropped (after fix_next_id such an id would select whatever
object is drawn next). Save and Edit Selection cap names the same way. The
limit, the name cleanup and the default "Selection N" live on
SavedSelection, shared by the engine, the Save Selection dialog and the
Select menu's slot table. docs/mcp.md documents the select.save /
savedList / recall / editSaved commands; ROADMAP counts 13 stubbed menu items
and names what's still missing (Update Selection, the Layers panel menu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:24:44 +07:00
bflatasticandClaude Opus 5.5 87d1acd589 MCP: panel names from the dock's own tables, object-read errors kept apart, docs (review of #328)
normalize_panel walks DockTab::ALL and ICON_PANELS instead of repeating the
dock tab ids. vectorcraft://object/{id} turns only a missing object into
-32602; no document or an unreachable app stay backend errors, as before.
docs/mcp.md documents document.node's summary flag and the panel names
open_panel takes; control-protocol.md the window.panel names. The ROADMAP
states the measured speed-up (a leaf read on 2,000 objects: ~20 ms to ~0.06 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:24:44 +07:00
bflatasticandClaude Opus 5.5 cea2ee6a54 Type: font menu previews end safely, rows and samples cost less per frame (review of #339)
- A preview whose menu has gone (the panel isn't drawn: text deselected,
  panel closed) or is closing (Escape, a press outside the menu) is undone
  at the start of the frame, before the canvas sees the input: a canvas
  drag no longer runs inside the preview's interaction and gets rolled
  back with it. The menu also ends the preview in the frame it closes.
- The list fits the popup (it was taller than the popup, which then
  scrolled too), so the current family is centred in view on opening;
  ↑/↓ scroll only as far as needed.
- Type › Font (in-window menu bar): each item keeps room for its sample
  after the name, so long family names no longer run under it.
- Rows are listed again only when the query, filters, expanded families,
  favourites or font list change, not every frame.
- Samples: one budget per frame across every menu (Type › Font gave each
  item its own), at most 8 rendering at once, the worker asks for a
  repaint when it is done instead of polling, the cache holds 512, and
  one function draws a sample for both menus.
- Font menus use menu_family_list (the hidden-family rule lives there);
  FontClass::label is gone (the menu's translated labels are the only
  ones).
- Tests: a preview left by a menu that is gone is undone; a press
  outside the open menu ends the preview in that frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:24:44 +07:00
bflatasticandClaude Opus 5.5 8f7949c30b i18n: Japanese rows for the Layers panel and GPU preference, consistency fixes (review of #317)
- 28 rows for strings that landed on main after #317 was written (Layers
  panel menu and options, Release to Layers, the Graphics Processor
  preference), so the Japanese catalog stays complete.
- Fixes: Live Paint Bucket is ライブペイントツール (was the selection
  tool's name), the File Info rating stars read {n} つ星 (were "rays"),
  Flare Tool Options and Symbol Sprayer match their tool names, Rectangular
  Grid matches its tool (長方形グリッド), recovery data is 復帰用データ
  throughout, (今後対応予定) for every roadmap note.
- Consistent spelling: フォルダー, 切り替え, Web サイト, 英語 (米国), and
  a half-width colon after every field label, as in the other 200 rows.
- tests: KEEPS_MENU_NAMES gets its own doc comment instead of taking
  MENU_KEEP_AS_IS's.
- ROADMAP and docs/development.md: Japanese ships complete, Czech covers
  the menus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:24:44 +07:00
bflatasticandClaude Opus 5.5 079c46dafb Type: the Mojikumi Set menu shows with the East Asian options, under its feature name (review of #350)
The Paragraph panel's mojikumi control is one of the panel's options
(hidden with Hide Options) and appears when Preferences > Type > Show
East Asian Options is on, as Illustrator shows its Mojikumi and Kinsoku
sets; it is labelled Mojikumi Set. ROADMAP notes where it lives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:24:13 +07:00
bflatasticandClaude Opus 5.5 e792d2a964 Type: vertical_glyph's doc names the two values it returns (review of #343)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:23:48 +07:00
Brandon ThomasandClaude Opus 5.5 5196337ca8 i18n pt-br: drop three Layers strings main no longer shows (#384 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:04:30 -04:00
Brandon Thomas 0cf2879d06 Merge pull request #384 from RicSchonfelder/i18n-pt-br
i18n: add Brazilian Portuguese (pt-br) UI translation

# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/i18n/tests.rs
2026-10-07 18:01:27 -04:00
Brandon ThomasandClaude Opus 5.5 4f159b98fb Merge pull request #376 from Haven-King/feat/artboard-snapping
Add Snapping to the Artboard Tool

Merged with the Artboard tool's Alt-drag duplicate (#348): a copy snaps too, and to the artboard
and art it leaves behind (a move skips them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:50:15 -04:00
Brandon Thomas 6b294c28b2 Merge pull request #373 from midasdf/fix/type-button-vertical-flyout
M7: expose Vertical Type in the basic Type tool flyout

# Conflicts:
#	ROADMAP.md
2026-10-07 17:49:18 -04:00
Brandon ThomasandClaude Opus 5.5 ecbb2c8c42 Type: the Japanese–Latin quarter em leaves Hangul alone and counts 々 〆 〇 as letters (#370 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:49:05 -04:00
Brandon Thomas e6da1ca3c3 Merge pull request #370 from imachus/pr/ja-wakan
Type: mojikumi — a quarter em between Japanese and Latin text

# Conflicts:
#	ROADMAP.md
2026-10-07 17:47:44 -04:00
Brandon ThomasandClaude Opus 5.5 5be1790717 PDF import (#337 follow-up): keep missing-font text live, decide each font's face once, Kangxi fix for CID text only
- Text in fonts that aren't installed comes in as live type in the fallback font again, named as
  before (Helvetica-Bold → Helvetica Bold), not outlines plus pink copies on a non-printing layer.
- The installed face that matches a PDF font is decided once per font from its first comparable
  glyph, not per glyph (loading up to 64 faces each time, and switching style mid-line).
- Kangxi radicals are unified only for text from Adobe's CID tables, not ToUnicode maps.
- A line is laid out again only when tracking changed it.
- CidText::of is tested on data that isn't a CID-keyed CFF (embedded fonts are untrusted).
- NOTICE: Adobe CMap resources (BSD-3-Clause) bundled through hayro-cmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:47:25 -04:00
RicSchonfelder 62d6ea4bed i18n: add Brazilian Portuguese (pt-br) UI translation
Add the crates/ui-egui/src/i18n/pt-br.tsv catalog (Brazilian Portuguese):
every menu label, every tl! literal of the shell and the plural messages
(2533 strings + 13 plurals), written clean-room in ordinary Brazilian
Portuguese computing vocabulary, placeholders and trailing ellipses kept
in lockstep with the English source.

Register the language in i18n::LANGUAGES (code pt-br, native name
"Português (Brasil)", one/other plural rule) so the Language menu, the
preference and the system-locale match (pt_BR → pt-br) pick it up.

Extend the menu-label test to pt-br: it covers every menu label like cs
and ja, and keeps the MENU_KEEP_AS_IS strings (product name, language
names, workspace names, perspective grid presets) in English.

Update docs/development.md and ROADMAP.md accordingly.
2026-10-07 18:44:42 -03:00
Brandon Thomas df31117196 Merge pull request #337 from imachus/pr/ja-pdf-text
PDF/.ai import: Japanese text as live type (CID fonts, vertical columns, missing fonts)
2026-10-07 17:38:15 -04:00
Brandon ThomasandClaude Opus 5.5 fc59594f6a i18n: Japanese for the strings main gained since #317 was written (Layers panel, saved selections, Delete Anchor Points)
#317 marks Japanese complete; the catalog tests require every menu string and tl! literal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:15:56 -04:00
Brandon ThomasandClaude Opus 5.5 c2f6508447 MCP: a server sends log records only when its own client asked for them
The log queue is process-wide. Any server draining it after a request, whether or not its client
called logging/setLevel, could take another session's records; under parallel tests that made
log_records_go_out_as_notifications_once_asked_for flaky.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:15:56 -04:00
Brandon ThomasandClaude Opus 5.5 440174d563 Type: rustfmt the merged vertical-glyph rotation (#343 + #350)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:04:17 -04:00
Brandon Thomas 219c8d4ae3 Direct Selection: keep the spine, mesh and frame overlays while a handle is dragged (#332 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 65a5c24081c733e70ad453318cedadf01ed6451a)
2026-10-07 17:03:49 -04:00
Brandon Thomas 1a5b3f00ba Font menu: keep at most 256 sample textures (#339 follow-up)
Each sample can take ~370 KB of texture; 3000 of them could hold about a gigabyte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d2daeefb6ef442a472696e78498deeedc03ca823)
2026-10-07 17:03:08 -04:00
Brandon Thomas b33e964aec Merge pull request #339 from imachus/pr/ja-font-menu
Type: a font menu with samples, live preview, script and kind filters, styles and favourites

# Conflicts:
#	crates/ui-egui/src/i18n/ja.tsv
2026-10-07 17:03:08 -04:00
Brandon Thomas 18df24c04d i18n tests: put MENU_KEEP_AS_IS's doc comment back on it (#317 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 030f8bc5344341b209b42b4a7e967cdaeeefed08)
2026-10-07 17:03:07 -04:00
Brandon Thomas 21b3dbf522 Merge pull request #317 from imachus/pr/ja-catalog
i18n: Japanese for the whole interface (panels, dialogs, tooltips, hints)

# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/i18n/ja.tsv
2026-10-07 17:03:07 -04:00
Brandon Thomas 8b4056aca6 Merge pull request #350 from imachus/pr/ja-mojikumi
Type: mojikumi — Line-end Punctuation Half Width for Japanese punctuation

# Conflicts:
#	ROADMAP.md
#	crates/text/src/layout.rs
#	crates/text/src/tests_vertical.rs
2026-10-07 17:03:07 -04:00
Brandon Thomas 062cbdc655 Merge pull request #344 from imachus/pr/ja-justify
Type: justified Japanese lines spread between their characters
2026-10-07 17:03:07 -04:00
Brandon Thomas 79bd76c7e9 Saved selections: keep Edit Selection linear in the number saved (#318 follow-up)
A file can hold any number of saved selections; the duplicate-name check and the name lookup were quadratic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 9bcb695ad3904565ed84b7562aa99dd818604cec)
2026-10-07 17:03:07 -04:00
Brandon Thomas e91183f0df Merge pull request #318 from MillhioreBT/feat/stubbed-menu-items
Select: saved selections live in the document, are listed in the Select menu and can be edited

# Conflicts:
#	ROADMAP.md
2026-10-07 17:03:07 -04:00
Brandon Thomas a3109c95f6 Merge pull request #343 from imachus/pr/ja-vmetrics
Type: vertical type takes upright glyphs' cells from the font's vertical metrics
2026-10-07 17:02:31 -04:00
Brandon Thomas 33a91ad140 Merge pull request #328 from fahrim/mcp-small-wins
MCP small wins: panel ids, object reads via document.node, stdio sweep
2026-10-07 17:02:30 -04:00
Brandon Thomas f020d6cb35 Merge pull request #331 from EthanSuquet/fix/eps-real-file-prolog
EPS import: EPS files from the reference app open as vectors
2026-10-07 17:02:30 -04:00
Brandon Thomas e47671ea32 Merge pull request #355 from fabian-co/pr/smart-guides-scale
Smart Guides: bounding-box resizes snap to the other objects

# Conflicts:
#	ROADMAP.md
2026-10-07 17:02:30 -04:00
BFlat 9c2f3886c7 Merge pull request #333 from EthanSuquet/fix/perspective-grid-hide
Perspective grid: Hide Grid works while a perspective tool is chosen
2026-10-08 04:00:12 +07:00
BFlat 95532ad2c8 Merge pull request #336 from theironchef/fix/grid-arrows-while-dragging
Grid tools: Left/Right arrows change the columns while dragging
2026-10-08 03:59:02 +07:00
BFlat bbb7e2839f Merge pull request #241 from realwitt/smart-remove
Path: Smart Remove Anchor Points keeps the curve close
2026-10-08 03:58:20 +07:00
BFlat 9ee0c4287f Merge pull request #332 from EthanSuquet/fix/handle-drag-shift-snap
Direct Selection: dragged direction handles take Shift and smart guides
2026-10-08 03:56:51 +07:00
BFlat 7ead49429e Merge pull request #308 from theironchef/fix/dialog-first-field-focus
Dialogs: a form dialog opens with its first field focused
2026-10-08 03:55:34 +07:00
BFlat e02d2f64d5 Merge pull request #298 from theironchef/fix/swatches-brushes-drag-and-drop
Swatches and Brushes panels: drag art in, drag a brush onto a path
2026-10-08 03:54:22 +07:00
midasdf b4ad8106d7 M7: expose Vertical Type in the basic Type tool flyout 2026-10-08 05:53:30 +09:00
BFlat 2bdb739383 Merge pull request #348 from theironchef/fix/artboard-alt-drag-duplicates
Artboard tool: Alt-drag duplicates the artboard (and its art)
2026-10-08 03:53:07 +07:00
BFlat 4a35e9c494 Merge pull request #351 from theironchef/fix/artboards-panel-activates
Artboards panel: clicking an artboard makes it the active one
2026-10-08 03:52:18 +07:00
BFlat de779cc820 Merge pull request #301 from theironchef/fix/layers-alt-click-others
Layers: Alt-clicking an eye or lock toggles the other layers
2026-10-08 03:51:27 +07:00
BFlat e99c9d1d6d Merge pull request #341 from theironchef/fix/sublayer-default-names
Tests: new layers and sublayers share one "Layer N" numbering
2026-10-08 03:50:52 +07:00
BFlat cc60c8711c Merge pull request #304 from theironchef/fix/space-moves-shape-while-drawing
Shape tools: holding Space while drawing moves the shape
2026-10-08 03:50:30 +07:00
BFlat af8b7c7b34 Merge pull request #312 from theironchef/fix/pen-auto-add-delete
Pen: Auto Add/Delete on a selected path
2026-10-08 03:49:42 +07:00
BFlat a4f88b5b31 Merge pull request #330 from theironchef/fix/pen-last-anchor-retracts-handle
Pen: clicking the last anchor retracts its handle instead of adding a duplicate point
2026-10-08 03:48:56 +07:00
BFlat 7487782dea Merge pull request #315 from theironchef/fix/pen-space-moves-anchor
Pen: holding Space while dragging out a point moves it
2026-10-08 03:48:30 +07:00
BFlat 2e071f9e0e Merge pull request #310 from theironchef/fix/pen-alt-mid-drag-keeps-curve
Pen: Alt pressed mid-drag keeps the curve into the new anchor
2026-10-08 03:47:48 +07:00
imachusandClaude Opus 5.5 5c1e78b2ab Type: mojikumi — a quarter em between Japanese and Latin text
Line-end Punctuation Half Width now also spaces Japanese from Latin
letters and digits (JLREQ 3.2.2): a quarter em of the Japanese
characters' size between a kana/kanji (or full-width letter) and a
Latin letter or digit, either way round — 雅楽 2026 年, not 雅楽2026年 —
horizontal and vertical (around sideways Latin).

- Not next to punctuation or symbols, not inside or around a
  tate-chu-yoko block (it is set as one Japanese character), and none
  left at the end of a line.
- Mojikumi None (older documents, imported text) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:47:16 +09:00
bflatasticandClaude Opus 5.5 b6bdef3de6 Perspective grid: coming back from a temporary tool keeps a hidden grid hidden (review of #333)
Choosing a perspective tool shows the grid, but the canvas also re-selected the tool after a
Cmd-drag with the borrowed Selection tool, so a grid hidden with the tool chosen came back after
any Cmd-drag. `Session::switch_tool` (and the app's `restore_tool`) switch without choosing; the
temporary-tool return uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:27:36 +07:00
EthanSuquetandClaude Opus 5.5 4a676c4d5e Fix #321: the perspective grid hides while a perspective tool is chosen
The grid was drawn whenever its `visible` flag was set or a perspective
tool was active, and choosing the Perspective Grid tool (Shift+P) didn't set
the flag. So with the tool chosen, View > Perspective Grid > Hide Grid
(perspective.grid.show) turned the hidden flag on instead of off and the
grid stayed on screen however it was toggled.

Choosing a perspective tool now shows the grid (sets `visible`, view state,
no undo step), and the grid, its widget and the Perspective Grid tool's
handles are drawn only while it is visible. Hide Grid hides it with the
tool still chosen; choosing the tool again shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:27:36 +07:00
bflatasticandClaude Opus 5.5 f17c34a067 Grid tools: note the arrow keys in the ROADMAP (review of #336)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:27:35 +07:00
theironchefandClaude Opus 5.5 f4b5029714 Fix #335: Left/Right arrows change a grid's columns while dragging
While a tool was busy (a drag in progress) the shortcut handler passed
only Up and Down to it, so the Rectangular and Polar Grid tools never
saw Left/Right, which add and remove columns (radial dividers). All four
arrows now go to the busy tool; the tools that use arrows for something
else (Gradient, Perspective Selection) ignore them while busy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:27:08 +07:00
bflatasticandClaude Opus 5.5 d5a060a009 Path: Remove Anchor Points is Object › Path's one item and shares the Delete Anchor Point refit (review of #241)
- Parity: Object › Path › Remove Anchor Points now runs the new command (`path.removeAnchors`,
  the menu's feature name) instead of `path.deleteAnchors`, which deleted the anchors with their
  segments and opened closed paths; that stays the Delete key's command ("Delete Anchor Points",
  off the menu). No second "Smart" item.
- Geometry: the refit moves to `vectorcraft_pathops::remove_anchor` on the existing least-squares
  fitter (fixed end tangents, a golden-section search for where the curve passes the removed
  point) instead of a ~1,000-candidate grid search in geom (about 4 ms per anchor, now well under
  0.1 ms). Two straight sides stay one straight side (a rectangle loses a corner and becomes a
  triangle), as Illustrator does; non-finite input leaves the handles alone.
- The Delete Anchor Point tool (`path.removeAnchor`) uses the same function, replacing its
  heuristic handle stretch; the explicit id/anchor and `fit` params of the new command are dropped
  as duplicates of it. The command is enabled by direct-selected anchors (`has_anchors`), not a
  UI override.
- The Control bar shows an icon button (the Delete Anchor Point icon, tooltip "Remove Anchor
  Points") instead of a 204 pt text button; the labels reuse the translated "Remove Anchor
  Points"; zh-hant gains "Delete Anchor Points". The Pen test that pinned Alt-click as plain Pen
  (Illustrator's Alt over an anchor is Convert Anchor Point) is dropped.
- Tests for the refit, closed paths, straight sides and bad input; docs/mcp.md and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:27:07 +07:00
Elias Witt f5c3ecb47c Path: Smart Remove Anchor Points is a menu command
path.smartRemoveAnchor stays. Object › Path, the Control bar, the contextual task bar, Properties, and the canvas context menu run it on direct-selected anchors. The Pen no longer uses Alt/Option for this, so that key stays free for Convert Anchor Point.
2026-10-08 03:25:44 +07:00
a1f2f23b72 Direct Selection: handle snapping ignores the path's moving bounds, Shift follows the Constrain Angle (review of #332)
- A dragged handle no longer snaps to its own path's bounds and centre, which move with the
  handle; the path's anchors are point and alignment targets instead.
- Shift steps 45° from the Constrain Angle preference, like the cut and gradient tools.
- snap_draw and snap_handle share one pixel/grid/smart-guides switch.
- A test that a smooth anchor's opposite handle turns with a Shift-dragged handle and that Alt
  still breaks it, and the tools' module docs (both from #338).

Co-authored-by: theironchef <6278036+theironchef@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:25:27 +07:00
EthanSuquetandClaude Opus 5.5 86de124cc8 Fix #322: dragged direction handles take Shift and smart guides
Dragging a direction handle with Direct Selection or the Anchor Point tool
sent the pointer position straight to path.setHandle: Shift did nothing and
the handle never snapped, while the Pen already constrains the handles it
pulls out.

guides::snap_handle places a dragged handle: with Shift at a multiple of
45° from its anchor (the Pen's constraint, keeping the dragged length),
otherwise snapped as a drawn point is (smart guides, the grid, pixels).
Both tools use it and show the smart guides while the handle moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:25:27 +07:00
bflatasticandClaude Opus 5.5 820f405c61 Dialogs: note first-field focus in the ROADMAP (review of #308)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:23:11 +07:00
theironchefandClaude Opus 5.5 aa9e2e89c8 Fix #307: a form dialog opens with its first field focused
Rotate, Scale, Move and the other form dialogs opened with no field
focused, so a typed value went nowhere and Enter applied the default (a
Rotate by 0 degrees). The frame a dialog opens on (after the window's
measuring frame), its first text or number field takes the keyboard
focus with its text selected, so typing replaces it and Enter applies
it, as in the reference app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:51 +07:00
bflatasticandClaude Opus 5.5 5e0c911633 Brushes: a brush dropped on a compound path's part applies to the compound (review of #298)
Use the paint owner, as a dropped swatch does. The Asset Export panel's drop zone uses
the shared art_drop too. Note the new drag and drop in the ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:50 +07:00
bflatasticandClaude Opus 5.5 17e654a738 Artboard tool: share the artboard copy with Duplicate Artboard, report the copies (review of #348)
artboard.move with copy and artboard.duplicate now make the copy with one helper: a
new id that can't overflow and a name of its own (`<name> copy`, then `<name> copy 2`…,
so two copies of one artboard no longer share a name). `moved` lists the copies a copy
moved rather than the originals it left. Test it with and without moveArt, and note
Alt-drag in the ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:50 +07:00
bflatasticandClaude Opus 5.5 3b2376af9c Artboards: one active artboard for the panel, the navigator and the Artboard tool (review of #351)
As in Illustrator, double-clicking an artboard's number goes to it (fits it in the
window) and double-clicking the name renames it. A row's Options button chooses the
Artboard tool on that row's artboard, clicking a row moves the Artboard tool to it
while it is chosen, and an artboard clicked with the Artboard tool becomes the active
one the panel highlights. Note it in the ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:50 +07:00
bflatasticandClaude Opus 5.5 c074a8031f Layers: Alt-click an eye or lock runs Hide/Lock Others and Show/Unlock All (review of #301)
Illustrator's Alt-click on a layer's eye hides the other layers and, once they are
hidden, shows every layer again; the lock column does the same with Lock Others and
Unlock All Layers. Reuse the panel menu's commands (one undo step each, named as the
menu items) instead of toggling the row's siblings, so a sublayer's or an object's row
stands for its top-level layer. Document it in the ROADMAP and docs/mcp.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:50 +07:00
bflatasticandClaude Opus 5.5 ad8c31b3af Layers: one next_layer_name, in the document (review of #341)
The engine kept its own copy of Document::next_layer_name after #353; the
document's now counts sublayers too, so the engine uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:50 +07:00
theironchefandClaude Opus 5.5 7830a41b3e Fix #297: the Swatches and Brushes panels take drag and drop
Art dragged off the canvas and released over the Swatches panel becomes
a pattern swatch of a copy of it (object.pattern.make without entering
pattern editing; the art stays), and over the Brushes panel an Art brush
(brush.new, as the panel's New Brush button makes). A brush dragged out
of the Brushes panel (PanelDrag::Brush, with a preview chip) is applied
to the path it is dropped on (brush.apply). The art drop zone helper
moves to widgets, shared with the Symbols panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:50 +07:00
theironchefandClaude Opus 5.5 41c7495844 Fix #346: Artboard tool: Alt-drag duplicates the artboard
Alt-dragging an artboard moved it like a plain drag. artboard.move now
takes copy: it leaves the artboard (and the art it carries) where it
is and moves a copy, named "<name> copy", with copies of that art. The
Artboard tool previews it while Alt is held, as "Duplicate Artboard"
(Alt pressed or released mid-drag switches between copy and move),
and makes the copy the active artboard, as in the reference app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:50 +07:00
theironchefandClaude Opus 5.5 a54be8298f Fix #349: clicking an artboard in the Artboards panel makes it active
The panel kept its own selection (a pstate nothing else read), so a
click only highlighted the row: the status bar navigator, Fit Artboard
in Window and Actual Size stayed on the previous artboard. The panel's
selection is now the view's active artboard (View::artboard): a click,
Move Up/Down and New Artboard set it, and the highlight follows the
navigator, per document, as in the reference app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:50 +07:00
theironchefandClaude Opus 5.5 59212cb0d4 Fix #299: Alt-clicking a layer's eye or lock toggles the others
In the Layers panel an Alt-click on an eye or lock now applies to the
other items beside that row (the other layers for a layer) in one
object.setProps step: it hides or locks them while any of them is shown
or unlocked, else shows or unlocks them all, as in the reference app. A
plain click still toggles only that row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:50 +07:00
theironchefandClaude Opus 5.5 6d1d94bf11 Fix #340: new layers and sublayers share one "Layer N" numbering
A new sublayer was named "Layer {node_count}", counting every object, so
an empty document's first sublayer was "Layer 1" like its parent and a
document with five objects got "Layer 6". Top-level layers counted only
top-level names and could repeat a sublayer's. Document::next_layer_name
now counts every layer, sublayers included, and skips any layer's name;
layer.newSublayer uses it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:50 +07:00
bflatasticandClaude Opus 5.5 3cd2fddeda Shape tools: Space moves Arc, Spiral and the grids too, through one helper (review of #304)
The Line Segment family (Arc, Spiral, Rectangular Grid, Polar Grid)
ignored Space while drawing, as the shape tools did before #302. Both
tools now share shape::space_moves. A headless canvas test presses Space
for real in the middle of a Rectangle and a Pen drag: the shape and the
anchor move and the view doesn't pan. Adds the ROADMAP entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:32 +07:00
bflatasticandClaude Opus 5.5 7feeb73a81 Pen: Auto Add/Delete reuses the Add/Delete Anchor Point tools' hit tests (review of #312)
The Pen's Auto Add/Delete had its own copy of the anchor and segment hit
tests and of the path.insertAnchor / path.removeAnchor actions the Add
and Delete Anchor Point tools build. draw2 now exposes anchor_in /
segment_in (the hit tests over a given set of paths) and insert_anchor /
remove_anchor, which both tools use. Adds the ROADMAP entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:31 +07:00
bflatasticandClaude Opus 5.5 0830ad68c3 Pen: ROADMAP entry for clicking the last anchor (review of #330)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:31 +07:00
bflatasticandClaude Opus 5.5 c322f24086 Pen: ROADMAP entry for Space moving the anchor being placed (review of #315)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:31 +07:00
bflatasticandClaude Opus 5.5 464a93c7f9 Pen: ROADMAP entry for Alt pressed mid-drag (review of #310)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:31 +07:00
theironchefandClaude Opus 5.5 736a1bde25 Fix #302: holding Space while drawing a shape moves it
The shape tools (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star,
Line Segment) ignored Space during a drag. While Space is held once the
drag has begun, the drag's start now moves with the pointer, so the
shape being drawn moves at its current size; letting go of Space resumes
sizing from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:31 +07:00
theironchefandClaude Opus 5.5 133390ef7a Fix #311: Pen: Auto Add/Delete on a selected path
Between paths, the Pen now adds an anchor where a click lands on a
segment of a selected path and deletes the anchor a click lands on,
with the add and delete cursors while hovering, as in the reference
app. The ends of a selected open path still continue it. Shift held,
or General > Disable Auto Add/Delete (a stored preference nothing read
until now, passed to tools as ToolContext::auto_add_delete), starts a
new path instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:31 +07:00
theironchefandClaude Opus 5.5 1b3a03316e Fix #314: Pen: clicking the last anchor retracts its handle
While drawing, a click on the last anchor appended a second anchor at
the same spot, keeping the old outgoing handle, so the path got a
zero-length curve that showed as a spike. Now a click there retracts
the anchor's outgoing handle (the next segment leaves it as a corner)
and a drag pulls a new outgoing handle out on its own, with a convert
cursor (^) over it, as in the reference app. A one-anchor path no
longer closes on itself when its anchor is clicked again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:31 +07:00
theironchefandClaude Opus 5.5 0b844a13af Fix #313: Pen: holding Space while dragging out a point moves it
The Pen ignored Space during a drag, so the anchor being placed stayed
where the button went down and its handles swung round it. Space held
now moves the anchor, handles and all, as far as the pointer moves
(the handle Alt kept moves along too); releasing Space goes back to
shaping the handles round the new place, as in the reference app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:31 +07:00
theironchefandClaude Opus 5.5 bef928bf17 Fix #309: Pen: Alt pressed mid-drag keeps the curve into the new anchor
Pressing Alt while dragging out a smooth point set its incoming handle to
the anchor itself, so the segment just shaped turned straight. The Pen
now remembers the incoming handle it last previewed and keeps it while
Alt is held, so only the outgoing handle follows the pointer: a corner
with curves on both sides, as in the reference app. Alt held from the
start of the drag still gives an outgoing handle only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:22:31 +07:00
BFlat 5cacbe36b3 Merge pull request #360 from storytold/pr/readme-screenshots
README: refresh the screenshots and renders, add a Perspective Grid shot
2026-10-08 02:42:07 +07:00
bflatasticandClaude Opus 5.5 1c3b7b16db README: refresh the screenshots and renders, add a Perspective Grid shot
- Five app screenshots retaken from the current build at 2240x1400 (UI at
  1.6x): the hero shows the Outer Glow settings in the Appearance panel, the
  Live Blends shot shows the new Layers panel rows, and a new shot shows a
  two-point Perspective Grid scene (examples/perspective-city.vectorcraft,
  built through the command API).
- "A look around" is a 2x2 grid; captions and alt texts follow the shots.
- The "Made in VectorCraft" art is re-rendered with vectorcraft-cli (the
  radial Repeat's gradients now turn with each instance).
- Every image is losslessly optimised with oxipng (7.2 MB -> 4.4 MB,
  pixel-identical).
- ASSETS.md rows for the new screenshot and example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 02:39:29 +07:00
Fabian GrajalesandClaude Opus 5.5 3bc4365ee2 Smart Guides: a turned bounding box snaps while it is resized
The resize snap skipped a bounding box standing at an angle. It now works
on the page whatever the angle: a corner handle takes the target's x, y or
both, and a side handle (or a proportional corner) slides along its own
direction until the handle or an end of its side is on a target. A target
the side runs almost along is skipped, so the handle stays near the pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j16ENkbsNYyHSCDhxYkYR
2026-10-07 14:32:11 -05:00
Brandon ThomasandClaude Opus 5.5 1f7873273b Rename PrintCraft to PdfCraft (storytold/pdfcraft) (#356)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:45:57 -04:00
haven 364c579c04 M11: artboards snap by their bleed; transform panel edits an active artboard 2026-10-07 11:42:00 -07:00
haven 9cd91deafa M11: Artboard tool snaps artboards while moving and resizing 2026-10-07 11:41:33 -07:00
Fabian GrajalesandClaude Opus 5.5 7de04544e4 Smart Guides: bounding-box resizes snap to the other objects
Dragging a bounding-box handle with the Selection or Free Transform tool
now lands the edges it moves on the edges and centres of the other objects
and artboards, with the guide line, as a move already did. A proportional
scale snaps the closer axis only; a turned bounding box doesn't snap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j16ENkbsNYyHSCDhxYkYR
2026-10-07 13:05:37 -05:00
BFlat 65c5953516 Merge pull request #353 from storytold/feat/layers-panel
Layers: the whole Layers panel, with sublayers, groups and objects as clickable rows
2026-10-07 23:49:12 +07:00
BFlatandClaude Opus 5.5 0648e99fc4 Layers: keep clippy happy (menu item type, no unreachable in tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:06:31 +07:00
BFlatandClaude Opus 5.5 ccdb4d89c1 Layers: the panel's rows work in the collapsed dock's flyout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:54:34 +07:00
imachusandClaude Opus 5.5 a92905a816 Type: mojikumi — Line-end Punctuation Half Width for Japanese punctuation
Japanese full-width punctuation is half a mark and half a space (an
opening bracket's space before it; a closing bracket's, comma's or full
stop's after it). Set solid, 」「 or 。」 show a full em of air between
the marks, and a line ending in 。 stops half an em short of the edge.

- `ParaStyle.mojikumi` (Paragraph panel › Mojikumi): None, or Line-end
  Punctuation Half Width, which (JLREQ 3.1.4) lets consecutive
  punctuation share one half-em space — a closing mark followed by
  punctuation loses the space after it, an opening bracket after another
  the space before it — and sets a closing mark ending a line half
  width. Only full-width marks (advance an em) are touched, never inside
  a tate-chu-yoko block; vertical type the same way down the column.
- New type (`text.create`, Area/Path Type) takes Line-end Punctuation
  Half Width, the usual default of Japanese layout; documents from
  before it and imported text keep None (written only when set, so
  older files and older apps are unaffected). `text.setFormat
  {mojikumi: "none"|"lineEndHalf"}`.
- Builds on #344: on justified lines the room it frees is spread
  between the characters.
- Catalog rows in ja and zh-hant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:46:07 +09:00
Fahri Meral 6f54843d5a Merge upstream/main: keep panel normalization alongside dock collapse behavior 2026-10-07 18:44:42 +03:00
BFlatandClaude Opus 5.5 f8e3cfbf35 Layers: document the Layers panel commands and update the ROADMAP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:29:15 +07:00
BFlatandClaude Opus 5.5 7eb3f25f1f Layers: sublayers, groups and objects are clickable rows in the panel
Rows at every depth highlight on click (Shift range, Ctrl toggle) and make
their layer current; selection squares select a row's art and drag the
selection to another row; eye and lock on every row with drag-down-the-column
in one undo step and Ctrl-click for Outline; Alt-click triangles; drag and drop
rows above, below or into layers and groups with a drop line (Alt copies);
Delete key; the full panel menu, Layer Options and Panel Options dialogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:29:15 +07:00
BFlatandClaude Opus 5.5 27a1a48176 Layers: sublayers are layers, not objects, and every row has a command
Art in a sublayer is now selected as objects (clicks, marquee, Select All, the
Select menu), sublayers select in their own colour, and hidden, locked or
template sublayers block clicks. Layer Options gain Preview (per-layer outline)
and Dim Images. New and fixed layer.* commands for the Layers panel: setCurrent
and highlight (rows at any depth), selectAll, move (reparent with cycle and
nesting checks, Alt copies), delete/duplicate rows, merge, flatten, collect,
release to layers, reverse, template, hide/outline/lock others and their
show/preview/unlock all, locate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:29:15 +07:00
BFlat f9209e8896 Merge pull request #347 from storytold/fix/issue-293
Fix #293: the dock's double arrow collapses its panels to icons
2026-10-07 22:27:32 +07:00
BFlatandClaude Opus 5.5 f16403a851 Fix #293: the dock's double arrow collapses its panels to icons
The » at the top of the right dock was only painted. Its header strip is
now a click target (hover highlight, "Collapse to Icons" tooltip) that
runs the new window.collapseDock UI command: the Properties | Layers |
Libraries group hides and its three panels become icons at the top of
the icon column, under a « strip ("Expand Panels") that expands them
again. While collapsed, those icons and window.panel pop the panel out
next to the column like the other icon panels (dock-width flyout, Layers
and Properties given a height that keeps them on screen); expanding with
one popped out shows its tab.

The flyout now follows the icon column's real left edge (recorded each
frame) instead of assuming a 300-point dock, so it also tracks a resized
or collapsed dock. The collapsed state is kept with the UI preferences
and in user workspaces; the built-in workspaces expand the dock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:21:59 +07:00
imachusandClaude Opus 5.5 5623f90ef7 Type: justified Japanese lines spread between their characters
Justify (last line left, centred or right) widened only word spaces, so
a Japanese line, which has none, stayed set solid and the right edge of
a justified Japanese paragraph was ragged by up to almost a character
(only Justify All spread the characters). Following JLREQ 3.8, the room
left on a line now goes to the gaps next to a CJK character, shared
with any word spaces:

- not inside a Latin word or a tate-chu-yoko block, nor next to a tab;
- lines without CJK characters are justified as before;
- vertical type too (the same code, in line space).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:20:18 +09:00
imachusandClaude Opus 5.5 681d18a08f Type: vertical type takes upright glyphs' cells from the font's vertical metrics
Upright glyphs in vertical type advanced one em (or their horizontal
advance, if wider) and turned about a fixed point 0.38 em above the
baseline. Fonts that carry vertical metrics now place them as they ask:

- `FontFace::vertical_glyph`: a glyph's advance down the column (`vmtx`)
  and the height of its vertical origin (`VORG`, else the glyph's top
  plus its top side bearing, as in TrueType fonts); `None` without
  `vhea`/`vmtx` or with values outside a few ems.
  `FontFace::ideographic_centre`: the em box centre from an ideograph's
  cell, else 0.38.
- The layout advances an upright glyph by its vertical advance and hangs
  it from its vertical origin; tate-chu-yoko blocks and point type's
  anchor use the font's em box centre. Fonts without vertical metrics
  are laid out exactly as before.
- Hiragino's tables give the values the layout assumed (one em, origin
  0.88 em), so Japanese vertical type in it renders pixel for pixel the
  same; this is the base for proportional vertical metrics (`vpal`) and
  for fonts whose em box sits elsewhere.
- `test_fonts::vertical_font`: Source Sans 3 with `vhea`/`vmtx` (and
  optionally `VORG`) built in code, one glyph 1.4 em tall hanging from
  1.1 em; tested with and without `VORG`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:12:44 +09:00
imachusandClaude Opus 5.5 52e1c0eca0 Type: a font menu with samples, live preview, script and kind filters, styles and favourites
The Character and Properties panels' font menu was a plain searchable
list of names. With hundreds of installed families (and Japanese names
that say little to someone who can't read them) a name isn't enough.

- Each row shows the family's name and a sample of the selected text set
  in it (a generic sample when the font lacks its characters). Samples
  are rendered off the UI thread, a few per frame, and only for the rows
  in view (the list is virtualised).
- The row under the pointer or reached with ↑/↓ previews its font on the
  selected text, as one live interaction with nothing in the history; a
  click or Enter applies it as one step, Escape or closing the menu puts
  the text back. The current family is highlighted and scrolled into
  view, also when the document names it in another language.
- Filters by script (Japanese, Latin) and kind (serif/Mincho, sans
  serif/Gothic, rounded, script/brush, monospaced, decorative), from the
  font's OS/2 classification and, for CJK fonts whose tables seldom say,
  its names. `FontTraits`/`FontClass` in the text crate; installed
  families get them from the same scan as their names.
- ▸ (or →/←) lists a family's styles; a style row previews and applies
  that style.
- ☆ stars a family; the ★ filter shows only the favourites (kept with
  the UI state).
- Preferences › Type › Enable in-menu font previews and Font Preview
  Size now turn the samples off and size the rows; the in-window Type ›
  Font menu (Windows, Linux) shows a sample beside each name.
- Families whose name starts with a dot (the system's own) are hidden.
- Catalog rows for the new labels in zh-hant (complete) and ja.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:51:33 +09:00
imachusandClaude Opus 5.5 fcaeaa74a7 PDF/.ai import: Japanese text as live type (CID fonts, vertical columns, missing fonts)
Japanese PDFs and PDF-compatible .ai files often set text in CID fonts
with no ToUnicode map, in vertical columns, and in fonts the reader
doesn't have. They came in as outlines (or as the wrong characters).

- CID fonts without ToUnicode: the character comes from the font's CFF
  charset through the standard CMaps (hayro's `embed-cmaps`); Kangxi
  radicals that some generators map to are unified with their ideographs
  (⿓ → 龍).
- A glyph becomes live type only when the installed font draws the same
  glyph: compared by box with the font of the PDF's PostScript name, or
  the closest style of its family (a variable font's named instances),
  whose style the type then takes. Otherwise it keeps its outline.
- Vertical columns (WMode 1, glyphs with no horizontal advance) come back
  as vertical type, letter spacing as tracking; a single upright glyph is
  vertical type too. Horizontal lines get tracking from the glyph
  positions the same way.
- Text in a font that isn't installed keeps its outlines (what prints),
  with an editable copy in a substitute font, drawn pink, on a
  non-printing layer named after the missing fonts (stroke-only glyphs
  included).
- Marked content other than a layer's (spans with actual text,
  artifacts) no longer ends a line of type: 篳篥・龍笛・笙 came back as
  three objects when one character was in a span.
- Tests in tests_live_text.rs and tests_realtext.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:45:31 +09:00
BFlat b64dcc02df Merge pull request #329 from storytold/fix/issue-306
Fix #306: render on the power-saving GPU by default, with a preference for the discrete one
2026-10-07 21:21:15 +07:00
BFlatandClaude Opus 5.5 406c27ebd8 Fix #306: render on the power-saving GPU by default, with a preference for the discrete one
On hybrid-graphics laptops (an integrated GPU driving the display plus a
discrete one), egui-wgpu's default power preference is HighPerformance, so
VectorCraft rendered on the discrete GPU and its frames were copied to the
display through the integrated one. On an Asus ZenBook with Intel Iris + Arc
the window flickered whenever the mouse moved over it (#306, #305).

The canvas is rasterized on the CPU (vello_cpu) and the GPU only composites
the canvas texture and draws the UI, so the integrated GPU is plenty. The
desktop app now asks wgpu for the power-saving adapter by default.
Single-GPU machines are unaffected.

- Preferences > Performance > Graphics Processor (`gpuPreference`:
  powerSaving, the default, or highPerformance), with a note that it
  applies after a restart. The app reads it from the saved preferences
  before the window opens. WGPU_POWER_PREF still overrides it.
- Help > About and `ui.inspect` (`graphicsAdapter`) name the adapter in
  use, and it is logged at startup, for future GPU bug reports.
- Docs (README, docs/development.md) and ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:10:17 +07:00
BFlat dfe7f1a311 Merge pull request #326 from storytold/fix/issue-296
Fix #296: variable fonts list and draw every named style
2026-10-07 21:09:48 +07:00
EthanSuquetandClaude Opus 5.5 7e0ed6b183 EPS import: EPS files from the reference app open as vectors
#249 made the reduced repros of #234 open, but a real file saved by the
reference app (version 30) still fell back to its screen-resolution preview
with "typecheck in `a dictionary`". Running its prolog to the end needed:

- The standard dictionaries (systemdict, userdict, globaldict, statusdict,
  errordict, $error) as values in systemdict, so `/globaldict where
  { /globaldict get begin } if` gets a dictionary, not an operator.
  SharedFontDirectory names the font directory too. errordict has a
  handleerror for prologs that wrap it.
- `dictstack`, `internaldict`, `currentsystemparams` and
  `currentuserparams` (with the cache sizes programs `get`), and `cshow`.
- Executable files: `cvx` and `cvlit` set a file's attribute, `xcheck`
  reads it, and executing one runs its data as a program, as the prolog
  does with `(...) 0 () /SubFileDecode filter cvx exec`.
- SubFileDecode with a count of 0 and an empty end string passes its
  whole source through instead of nothing.
- `flushfile` on a filter over the program's data skips that data (the
  file's XMP packet). A filter chained from a pending one leaves it
  nothing to skip, so the writer's own `VCsrc flushfile` is unchanged.

The interpreter empties its standard dictionaries when it finishes, since
systemdict now holds itself. Both real files tested now import as 15
vector paths in CMYK with no warnings, and round-trip through SVG and PDF
pixel for pixel. The EPS fuzzer exercises the new operators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:06:56 -04:00
BFlatandClaude Opus 5.5 079f9a8b9a Fix #296: variable fonts list and draw every named style
The font database made one style per font file, so a variable font
such as Figtree[wght].ttf listed only its default instance (Light).
Faces now also yield their fvar named instances, each a style of the
family named by its subfamily name (the default instance is the face
itself; unnamed or duplicate instances are left out; axes and
instances read are capped). The system font scan reads the fvar table
beside the name table, so installed instances are listed before the
file is loaded and found by name and by PostScript name.

An instance face carries its axis settings: skrifa draws its outlines
and reads its metrics and advances at that location, harfrust shapes
with a ShaperInstance, so layout, the canvas, Create Outlines and hit
testing all use the instance. PDF export embeds it through krilla's
Font::new_variable, SVG @font-face through subsetter's
subset_with_variations: both write a static font of that instance.

Tests build a small variable font in code (test_fonts, via
write-fonts; no font file is committed) and check listing, resolving,
glyphs and advances, PDF and SVG export and damaged fvar/gvar tables;
Bahnschrift and Segoe UI Variable are checked where installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:01:26 +07:00
Fahri Meral 6f9b160038 Merge branch 'main' of https://github.com/storytold/vectorcraft into mcp-small-wins 2026-10-07 16:58:14 +03:00
Fahri Meral dcbe59dae9 docs: note panel ids, node summary reads and stdio sweep in ROADMAP 2026-10-07 16:56:28 +03:00
BFlat 8e40794153 Merge pull request #325 from storytold/fix/issue-290
Fix #290: hovering another menu title switches the open menu
2026-10-07 20:54:33 +07:00
Fahri Meral d370f0a542 mcp: sweep protocol surface over stdio against the binary 2026-10-07 16:39:06 +03:00
Fahri Meral e5e09d5d48 mcp: read object template via document.node summary flag 2026-10-07 16:39:06 +03:00
Fahri Meral d9136c46b9 mcp: accept panel ids case-insensitively and by display label 2026-10-07 16:39:06 +03:00
BFlatandClaude Opus 5.5 ee7a7ebfac Fix #290: hovering another menu title switches the open menu
On the in-window menu bar (Windows and Linux) each top-level menu needed
its own click: with File open, moving the pointer onto Edit or Object did
nothing. Now, as on a native menu bar, while one menu is open a moving
pointer that reaches another title opens that menu instead (ported from
PhotoCraft). A pointer resting on a title doesn't switch, and a tall menu
that covers the bar doesn't switch to the title underneath it. The native
macOS menu bar is unchanged.

Also adds a regression test for #292: the Simplify, Offset Path, Move,
Rotate, Split Into Grid and Average dialogs are 366 pt wide in a 1600 pt
window (already fixed by #239).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:32:03 +07:00
BFlat 0d4d125f9b Merge pull request #323 from storytold/fix/issue-291
Fix #291: live corners stay circular when a rectangle is scaled unevenly
2026-10-07 20:28:06 +07:00
BFlatandClaude Opus 5.5 deaec7fc71 Fix #291: live corners stay circular when a rectangle is scaled unevenly
A live rectangle kept every transform in its `xf`, so a non-uniform scale
(a bounding-box drag, Object > Transform > Scale) stretched the rounded
corners built in untransformed space into elliptical arcs. Scale Corners
off divided the radii by the mean scale, which only compensated uniform
scales.

LiveShape::transform now folds a scale along the rectangle's own sides
(any rotation or reflection, no shear) into w/h and keeps only rotation,
reflection and position in `xf`, so the radii are document lengths and the
path is regenerated with circular corners. Radii scale by the mean scale
(sqrt|det|), which keep_corners undoes for Scale Corners off. Shears and
plain moves keep composing into `xf` as before, so files saved with a
scale in `xf` keep their exact geometry until next scaled or rotated.
A corner-widget drag starts from the radius as drawn (clamped to fit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:25:41 +07:00
Sarah Wesker a40260a441 Select: saved selections live in the document, are listed in the Select menu and can be edited
Save Selection… asks for a name and keeps the selection in the document (saved with
the file, undoable, at most 25) instead of in session memory keyed by the title.
Each saved selection is listed at the bottom of the Select menu and a click selects
its objects again. Edit Selection… opens a dialog with the list to rename or delete
them, applied together on OK as one undo step (select.editSaved takes `edits`).

The menu stub count in the ROADMAP goes from 16 to 15.
2026-10-07 08:58:33 -04:00
imachusandClaude Opus 5.5 ef167a1345 i18n: Japanese for the whole interface (panels, dialogs, tooltips, hints)
Closes #217. The Japanese catalog now covers every menu string and every
tl! literal, so `complete_menus` is on for `ja` and the tests keep it
complete.

- ja.tsv: about 2,300 rows added after the existing menu rows, which are
  unchanged: panels, dialogs, tooltips, the hint bar, document tabs,
  status messages and the strings built at run time (section lists,
  counts, `@plural` rows). Written from the meaning of the English
  labels in the vocabulary Japanese users of vector illustration
  software know; product and format names stay in Latin letters, the
  trailing "…" is kept, and placeholders appear in both columns.
- Kept in English, as before: the product name, OpenType, the built-in
  workspace names and the bracketed perspective preset names.
- tests: the languages that keep those names in English (`cs`, `ja`) are
  listed once (`KEEPS_MENU_NAMES`), and the completeness test skips the
  kept names for them, so a language can keep them and still be
  complete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:36:51 +09:00
BFlat a26aa5b203 Merge pull request #294 from storytold/pr/release-0.4.0
Release: VectorCraft v0.4.0
2026-10-07 16:37:19 +07:00
bflatasticandClaude Opus 5.5 7be80776f2 Release: VectorCraft v0.4.0
Bump the workspace version 0.3.1 -> 0.4.0 (cargo xtask version set 0.4.0).
Minor release: 50 pull requests since v0.3.1 (#128-#289).

What changes for users:
- Interface languages: VectorCraft > Language picks Automatic or a
  language; Traditional Chinese covers the whole interface, Czech and
  Japanese every menu.
- Vertical type: kinsoku, tate-chu-yoko and upright CJK glyphs, each
  character in its own cell down the column.
- Area type resizes its frame with the Selection tool's handles and is
  reshaped with Direct Selection; the text reflows at its size.
- Canvas and tools: right-click context menu, guides dragged out of the
  rulers, Cmd+Space / Cmd+Alt+Space zoom, Cmd drags with the selection
  tool used last, double-clicking type places the caret, tool
  double-clicks (Hand, Zoom, Rotate, Scale, Reflect, Shear), Free
  Transform distort and shear by the keys held during a drag, the status
  bar's artboard navigator, Symbols panel drag and drop, Shift+punctuation
  shortcuts.
- Live Paint treats every path as an edge; Effect > Pathfinder works on
  several objects.
- Files: EPS files from other apps open; PDF/.ai import finds fonts by
  PostScript name, brings type on a path back as type on a path and keeps
  each artboard's art on its own page; DXF Fit/Aligned text.
- MCP: prompts, resource templates, completions and logging; tool
  descriptions and replies match the engine.
- Platforms: FreeBSD and Windows on ARM64 release packages; Japanese
  fonts come from craft-fonts at build time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:33:13 +07:00
BFlat a9c31fcbe7 Merge pull request #287 from p0lyg0n/test/japanese-file-names
Tests: Japanese file and folder names save, open, export and title documents
2026-10-07 13:31:38 +07:00
BFlat 89ff09e9fd Merge pull request #286 from p0lyg0n/fix/ai-neighbouring-artboards
PDF/.ai import: art of a neighbouring artboard stays on its own page
2026-10-07 13:31:29 +07:00
BFlat 71efade26b Merge pull request #288 from theironchef/fix/symbols-drag-and-drop
Symbols panel: drag art in to make a symbol, drag a symbol out to place it
2026-10-07 13:31:20 +07:00
BFlat 6a62dc58ba Merge pull request #284 from theironchef/fix/double-click-type-caret
Canvas: double-clicking type with a selection tool puts the caret there
2026-10-07 13:31:10 +07:00
BFlat c462057460 Merge pull request #282 from theironchef/fix/shifted-punctuation-shortcuts
Shortcuts: chords with Shift and a punctuation key fire as typed
2026-10-07 13:31:02 +07:00
Hiroshi InukaiandClaude Opus 5.5 539dcb84b7 Tests: Japanese file and folder names save, open, export and title documents
A regression test for non-ASCII paths. It saves to a Japanese file name, with
full-width brackets and a middle dot, inside a Japanese folder. It checks the
document's title, reopens the file with its Japanese text, and exports SVG, PNG
and PDF to Japanese names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:20:07 +07:00
Hiroshi InukaiandClaude Opus 5.5 d5e21cc735 PDF/.ai import: art of a neighbouring artboard stays on its own page
A file with several artboards writes, on each page, the art of its
neighbours that reaches the page's box, shifted by its own artboard
spacing. Kept, those copies drew over the art of the page they came from
(a hologram over its card's stripes and title). With several pages, art
wholly outside a page's box now stays with the page it belongs to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:20:07 +07:00
theironchefandClaude Opus 5.5 bc688bd1b5 Fix #285: the Symbols panel takes drag and drop both ways
Art dragged off the canvas and released over the Symbols panel's list
becomes a new symbol (symbol.new with the dragged ids), and a symbol
dragged out of the panel (PanelDrag::Symbol) is placed on the canvas
centred where it is dropped (symbol.place), showing its thumbnail at
the pointer on the way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:20:07 +07:00
theironchefandClaude Opus 5.5 11799fa0cc Fix #283: double-clicking type with a selection tool puts the caret there
The Selection tool's double-click on text only switched to the Type
tool, which then had nothing being edited, so typing went to the tool
shortcuts (X swapped the text's fill and stroke). After such a switch
the canvas passes the double-click on to the Type tool as a click, which
starts editing the type with the caret where it was clicked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:19:27 +07:00
theironchefandClaude Opus 5.5 b6649a3fad Fix #280: shortcuts with Shift and a punctuation key fire as typed
egui reports the character a key types, so Cmd+Shift+[ arrives as
Cmd+Shift+{ (], / likewise as }, ?) and Shift+` as the text ~, and these
default shortcuts never matched: Bring to Front, Send to Back, Search
Commands and the Curvature tool. Command chords now also take the
shifted character, single-key tool and command shortcuts also look up
Shift+<typed text>, and the Keyboard Shortcuts editor records such a
chord as its key (Cmd+Shift+], not Cmd+Shift+}), so conflicts show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:19:25 +07:00
BFlat 49dc3b90be Merge pull request #278 from p0lyg0n/fix/pdf-type-on-a-path
PDF/.ai import: type set on a path comes back as type on a path
2026-10-07 13:13:55 +07:00
BFlat c256818665 Merge pull request #279 from theironchef/fix/ctrl-space-zoom
Canvas: Ctrl+Space zooms (Ctrl+Alt+Space out), Space alone pans
2026-10-07 13:13:41 +07:00
BFlat a20e680f62 Merge pull request #276 from theironchef/fix/temp-selection-tool
Canvas: Ctrl with another tool drags with the selection tool used last
2026-10-07 13:13:28 +07:00
BFlat 68a14472c6 Merge pull request #289 from storytold/fix/issue-252
Fix #252: area type resizes its frame with the mouse
2026-10-07 13:13:14 +07:00
Hiroshi InukaiandClaude Opus 5.5 e2227849a1 PDF/.ai import: the last glyph of type on a path stays on its path
The path rebuilt through the glyphs ended where the last glyph ends, so
rounding in the spacing could push that glyph past the end, where type on
a path stops ("OFFICIAL ISSUED" lost its D). The path now runs an em
further.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:03:00 +07:00
theironchefandClaude Opus 5.5 3c096da84d Fix #277: Ctrl+Space zooms (Ctrl+Alt+Space out), Space alone pans
Held Space always panned, whatever else was held, so the temporary Zoom
tool keys did nothing. Held Space is now the Hand tool and Cmd+Space the
Zoom tool (Alt: out), whatever the active tool, for the press and the
cursor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:03:00 +07:00
theironchefandClaude Opus 5.5 47104f5f4c Fix #270: Ctrl with another tool drags with the selection tool used last
The canvas always switched to the Selection tool for a Cmd press with a
non-selection tool. The UI state now remembers which of Selection,
Direct Selection and Group Selection was used last, and the temporary
switch uses it, so Cmd-dragging an anchor while drawing with the Pen
after using Direct Selection moves that anchor, not the whole path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:03:00 +07:00
Hiroshi InukaiandClaude Opus 5.5 ea5a04504a PDF/.ai import: type set on a path comes back as type on a path
Apps write type on a path one glyph at a time, each placed and turned along the
curve. The importer joined glyphs into a line only while they kept the first
glyph's direction. Type on a gentle curve was flattened into straight lines, and
type on a tighter curve fell apart into one object per glyph.

A glyph that starts about where the previous one ends, turned a little from it
(under about 20°), now continues the line. A line whose baseline turned more than
about 3° becomes type on a path: its path is a smooth curve (Catmull-Rom as cubic
Béziers) through the glyphs' baseline origins and the end of the last glyph.
Spaces between words are measured along the last glyph. Straight type, rotated or
not, imports as before.

Test: "ARCHING" written glyph by glyph along an arc imports as one
type-on-a-path object with that text and a curved path; slightly rotated straight
type stays point type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:03:00 +07:00
bflatasticandClaude Opus 5.5 d6eb526186 Fix #252: area type resizes its frame with the mouse
Dragging a bounding-box handle of area type with the Selection tool now
resizes the type area: the text reflows at its own size (threads too)
instead of being scaled, and History says "Resize Type Area". With other
objects selected they scale as before while the area type resizes its
frame. Direct Selection picks the frame's corners and edges and reshapes
the area ("Reshape Type Area", new command text.reshapeArea), and the
selected frame's anchors and outline show. Area Type Options gains Width
and Height. Point type, rotation, Object > Transform > Scale, the Scale
tool and the Transform panel still transform the type.

Root cause: every handle drag previewed object.transform, which folds the
matrix into the text's xf (TextObject::transform), so area type scaled its
glyphs; and Direct Selection's anchor hit test only looked at paths, so the
frame's corners could not be picked. object.transform now takes
typeAreas, which reshapes the frame in text space (xf^-1 * m * xf), and
the tools use it and text.reshapeArea.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:02:55 +07:00
BFlat ce3683a508 Merge pull request #281 from storytold/fix/issue-260
Fix #260: tate-chu-yoko numbers and upright glyphs keep their own cell in vertical text
2026-10-07 12:32:51 +07:00
BFlat 333a487dab Merge pull request #269 from theironchef/fix/free-transform-modifiers
Free Transform: a handle drag follows the modifiers held during the drag
2026-10-07 12:31:52 +07:00
BFlat b8f77aefb6 Merge pull request #267 from theironchef/fix/artboard-navigator
Status bar: the artboard navigator works, and fitting shows its artboard
2026-10-07 12:31:37 +07:00
BFlat 40bdfd3aca Merge pull request #265 from theironchef/fix/tool-double-clicks
Toolbar: double-clicking Hand, Zoom and the transform tools works
2026-10-07 12:31:25 +07:00
bflatasticandClaude Opus 5.5 dd595cf2e8 Fix #260: tate-chu-yoko numbers and upright glyphs keep their own cell in vertical text
In vertical type every character now sits in its own cell down the column:
"10" and "22" in 「10月22日」 stand side by side in one em, centred on the
column, and 月/日 start right after them. Justified (Justify All) columns
no longer split a tate-chu-yoko block, and tracked or justified upright
characters stay on the column's centre line instead of drifting across it.

Root cause: in 0.3.1 (the reporter's build) upright glyphs were placed by
the font's ascent rather than the ideographic em box, so a font with a tall
ascent (Noto Sans CJK JP, 1.16 em) drew each upright character ~0.28 em
down into the next cell, onto the sideways digits; #208 (after 0.3.1)
replaced that with em-box placement and tate-chu-yoko, which this commit
locks in with regression tests. Two defects of the same kind remained:
Justify All added its inter-character gap inside a tate-chu-yoko block
(the block's second digit slid down the column, away from the first), and
upright glyphs turned about the middle of their advance, which tracking and
justification lengthen, instead of the middle of their own cell.

Tests: the issue's layout with bundled glyphs (cells one after the other,
every ink inside its cell and no overlaps, 2-3 digit blocks one em and
centred, single digits and 2026 on their side advancing by their length,
spaces), the issue's string with craft-fonts' Japanese face (skips without
craft-fonts), Justify All with a block, tracking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:16:44 +07:00
theironchefandClaude Opus 5.5 614606cc48 Fix #268: Free Transform follows the modifiers held during a handle drag
The tool chose scale, distort, perspective or shear from the modifiers at
the press only, but a Cmd press on the canvas is the temporary Selection
tool, and the documented gesture is to start dragging and then hold Cmd.
A handle drag now follows the modifiers held during the drag; when the
operation changes, the interaction restarts so the undo step is named
after what was done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:15:46 +07:00
theironchefandClaude Opus 5.5 13f65ce6d4 Fix #266: the status bar's artboard navigator works, and fitting shows its artboard
The navigator's buttons were painted only and its number was always 1,
and Fit Artboard in Window and Actual Size always used the first
artboard. The per-document View now keeps the navigator's artboard; the
new view.goToArtboard UI command (an index, or first / previous / next /
last) sets it and fits it in the window, and the four buttons call it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:15:46 +07:00
theironchefandClaude Opus 5.5 308c096889 Fix #264: double-clicking Hand, Zoom and the transform tools works
A double-click on a tool button runs tool.options, which only knew a few
tools, so the common double-clicks did nothing: the Hand tool now fits
the artboard in the window, the Zoom tool shows 100%, and Rotate, Scale,
Reflect and Shear open their Object > Transform dialog (when something
is selected, like the menu items).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:15:45 +07:00
BFlat ba2ee7a174 Merge pull request #275 from storytold/fix/issue-257
Fix #257: format lists name every format the engine reads and writes
2026-10-07 12:13:50 +07:00
BFlat b743d08f11 Merge pull request #274 from storytold/fix/issue-258
Fix #258: the save_file tool says what it saves
2026-10-07 12:13:39 +07:00
BFlat ab2daad763 Merge pull request #273 from storytold/fix/issue-259
Fix #259: the docs say document.inspect reports paint as a label
2026-10-07 12:13:32 +07:00
BFlat 80323a7a4e Merge pull request #272 from storytold/fix/issue-256
Fix #256: inspect_document reports the paint type's characters show
2026-10-07 12:13:24 +07:00
BFlat 5f59332ec8 Merge pull request #271 from storytold/fix/issue-255
Fix #255: selection exports report the encoder's warnings
2026-10-07 12:13:13 +07:00
bflatasticandClaude Opus 5.5 926cdc80a5 Fix #257: format lists name every format the engine reads and writes
The export tool's description, the docs' open_file and export rows and the
CLI's run/convert usage listed only some formats. The CLI usage now prints
the writable extensions next to the readable ones, both generated from the
format table (export_extensions), the export tool points to its generated
format enum, and the docs name EPS, DXF, EMF, WMF, SVGZ and templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:10:12 +07:00
bflatasticandClaude Opus 5.5 b55e3ad70a Fix #258: the save_file tool says what it saves
It ran document.save, which keeps the document's own file and format (or
takes the format the path's extension picks), but described itself as a
native .vectorcraft save. The description now says so, with the formats
generated from SAVE_FORMATS, and points to export for the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:09:42 +07:00
bflatasticandClaude Opus 5.5 48c96b504d Fix #259: the docs say document.inspect reports paint as a label
paint.proxies gives swatch-linked paint as {type, color, swatch, tint};
document.inspect gives it as a label such as "G1 40% (#ff9999)". The tints
section and the inspect_document row now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:49:22 +07:00
bflatasticandClaude Opus 5.5 2e134e56be Fix #256: inspect_document reports the paint type's characters show
A type node's fill, stroke and strokeWidth now come from its first run, as
paint.proxies and its strokeOptions already did; fills and strokes of the
type object itself are reported apart as objectFill, objectStroke and
objectStrokeWidth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:48:54 +07:00
bflatasticandClaude Opus 5.5 a01e045905 Fix #255: selection exports report the encoder's warnings
document.exportSelection (the MCP export tool's `selection: true`) now
replies with `format` and the encoder's `warnings`, the same list a
whole-document export gives: lossy WebP written lossless, blend modes left
out of SVG Tiny 1.2, PDF layers at 1.4 and the rest. A selection export
that approximates nothing replies with an empty list.

Root cause: export_selection encoded with `encode`, which drops the
warnings encode_with_warnings returns, and its reply carried only path,
bytes and bounds. The params doc and docs/mcp.md now describe the reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:42:05 +07:00
BFlat eac383d5af Merge pull request #262 from p0lyg0n/i18n/japanese-menus
i18n: Japanese translates every menu label
2026-10-07 11:39:13 +07:00
BFlat 94bc59b068 Merge pull request #263 from theironchef/fix/ruler-guides
Canvas: a drag from a ruler onto the canvas makes a guide
2026-10-07 11:39:07 +07:00
theironchefandClaude Opus 5.5 c6ac2bf3c5 Fix #247: dragging from a ruler onto the canvas makes a guide
The rulers were only painted. A drag from the top ruler now previews a
horizontal guide and adds it with guide.add where the button is released
on the canvas; the left ruler makes a vertical one. Released anywhere
else, nothing is added. Guides are shown when one is made this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:34:27 +07:00
Hiroshi InukaiandClaude Opus 5.5 198725ed38 i18n: Japanese translates every menu label
Japanese covered 36 menu labels after #145; every other label stayed
English. ja.tsv now holds all 556 interface menu labels, in menu order,
including both readings of every toggle, plus the labels added since: the
Language menu's Automatic and the canvas context menu's. #145's 36 entries
are unchanged. Kept in English, as for Czech: the product name, language
names, OpenType, the built-in workspace names and the bracketed perspective
grid presets.

The Czech coverage test becomes menu_catalogs_translate_every_menu_label,
run for Czech and Japanese. ROADMAP and docs/development.md say both cover
every menu label.

Ported by the maintainers from the Rust table of the original PR onto the
catalog framework (#205): the placeholder labels of recent-file, recent-font,
user-preset and user-library slots are left out (the menus show the names).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:33:45 +07:00
BFlat 7ce27caf88 Merge pull request #261 from storytold/fix/palette-shortcuts
Palette: entries rebuild when keyboard shortcuts change
2026-10-07 10:56:05 +07:00
c91ca3634f Palette: entries rebuild when keyboard shortcuts change
The command palette caches its entries per language and plug-in set
(#243), but each entry also shows its shortcut: after editing shortcuts
the palette kept showing the old ones until the language changed. The
cache key now includes the shortcut editor's generation. From #242.

Co-authored-by: WildfootW <wildfootw@wildfoo.tw>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:55:55 +07:00
BFlat 24f7d5fba5 Merge pull request #253 from theironchef/fix/area-type-frame-offset
Type tools: a dragged area-type frame is the dragged rectangle
2026-10-07 10:55:41 +07:00
BFlat cc4992172a Merge pull request #246 from theironchef/fix/type-drag-select
Type tool: a drag across text that isn't being edited selects it
2026-10-07 10:55:36 +07:00
BFlat 9dc05ac980 Merge pull request #254 from Luskish/fix/control-close-on-malformed-input
Control: close the connection on input that isn't a request
2026-10-07 10:55:30 +07:00
Luskish 339eb78a9b Control: close the connection on input that isn't a request
A line that is not a JSON object with a string `method` (an HTTP request
line, other non-JSON text, a JSON array, invalid UTF-8, a line over 4 MiB)
now gets one error reply and the connection is closed, so a browser's
cross-origin fetch can't smuggle a command in its body. Connections are
capped at 16. Same transport as LightCraft (storytold/lightcraft#129) and
FilmCraft (storytold/filmcraft#123).
2026-10-06 22:54:21 -04:00
theironchefandClaude Opus 5.5 e01077a674 Fix #250: an area-type frame dragged with the Type tools is the dragged rectangle
The Type tool passed `y + 12` to `text.create` for a dragged frame, and
`text.create` puts the frame's top-left at (x, y), so every frame landed
12 pt below the marquee the user dragged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:44:20 -07:00
BFlat 6313c8cfae Merge pull request #251 from storytold/docs/roadmap-catch-up
ROADMAP: the DXF Fit/Aligned text, Effect > Pathfinder and translation fixes
2026-10-07 09:44:16 +07:00
bflatasticandClaude Opus 5.5 f9a89a3211 ROADMAP: the DXF Fit/Aligned text, Effect > Pathfinder and translation fixes
#226, #227 and #243 landed without their ROADMAP lines: Shipped so far now
says DXF Fit and Aligned text span their two points, Effect > Pathfinder
groups several loose objects first, and names are never translated; the
Last updated line counts the 2026-10-07 issue fixes. Scores unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:40:43 +07:00
BFlat fbcadf317c Merge pull request #249 from storytold/fix/issue-234
Fix #234: EPS files from the reference app open
2026-10-07 09:39:51 +07:00
bflatasticandClaude Opus 5.5 64891c1a2e Fix #234: EPS files from the reference app open
EPS files the reference app saves (its procsets in the prolog, a binary header with a TIFF
preview) now open as editable art instead of failing with "undefinedresource in Generic". When
their PostScript still can't be read, the palette preview they carry is placed with the usual
warning instead of the file being refused.

Root cause: five interpreter gaps and one preview format. The Generic and Category resource
categories didn't exist, so categories couldn't be made from Generic (now Category holds the
standard categories, defineresource in Category makes new ones, undefineresource is added);
clipsave/cliprestore were unknown (now a clip stack in the graphics state); systemdict's
languagelevel was the operator, not the integer 3; getinterval copied arrays and strings
instead of sharing their storage, so astore into a subarray was lost (arrays and strings are
now views into shared storage, and readstring/readline return the part they fill); and
resourceforall was unknown (now enumerates matching names with * ? \ templates). The preview
is an 8-bit palette TIFF with an alpha sample, which the image crate can't decode: a small
bounds-checked reader handles that layout (either byte order, any strips).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:34:27 +07:00
theironchefandClaude Opus 5.5 c60cb4a9c2 Type tool: a drag across text that isn't being edited selects it
A press only started a selection inside the text being edited; on other
type it waited for the release, which placed the caret at the press and
ignored the drag, so the first drag across a word never selected it.
A press on type now starts editing it there (the same code a click used
on release), so the drag selects at once; a plain click still just
places the caret.

Fixes #245.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:24:22 -07:00
BFlat 83faf568b0 Merge pull request #244 from storytold/fix/issue-231
Fix #231: docs/releasing.md describes the release pipeline
2026-10-07 09:19:52 +07:00
bflatasticandClaude Opus 5.5 ee1e95d1c7 Fix #231: docs/releasing.md describes the release pipeline
release.yml's comments sent readers to docs/releasing.md, which never
existed: the workflow was ported from a sibling app without its document.
docs/releasing.md now covers how a release is cut (version bump with
cargo xtask version, the release branch, the draft, publishing, test runs
with a version override), what each job builds and how (macOS universal
signing and notarization, Windows x64/x86/arm64 with the PE checks and the
two signing options, Linux packages and the glibc 2.35 floor behind the
ubuntu-22.04 runners, FreeBSD in a VM, the web build, craft-fonts), the
draft release job and the signing secrets. Every detail is checked against
this repository's workflow and packaging scripts. The README links it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:17:10 +07:00
BFlat e960baa131 Merge pull request #243 from storytold/fix/i18n-review-followups
i18n: names are never translated; palette cache; locale probe off the UI thread
2026-10-07 08:43:42 +07:00
bflatasticandClaude Opus 5.5 0e5e093dd9 i18n: one label_or_name for lists mixing labels and names; normal style rows
panels::label_or_name and dialogs::shown_names applied the same rule; it
now lives once as i18n::label_or_name (explicit language, testable) and
both delegate to it. zh-hant gains the two normal text style names the
Character and Paragraph Styles panels translate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:37:43 +07:00
bflatasticandClaude Opus 5.5 6572636d62 i18n: panels show user, file and system names as they are
Panels passed names to the translating widgets, so a name that happened to
be a catalog key was translated (a font style "Regular", a workspace called
"Layers"). Font styles, pattern names, recent URLs, ICC profiles, saved
width profiles, user and loaded libraries, plug-in effects and their
parameters, Document Info's artboard/style/font/image rows, text-object
contents in Layers, user text styles in brackets, the Artboard tool's
canvas label and the app-bar workspace switcher now show names verbatim;
built-in entries (Working CMYK, (closest), built-in libraries and effects,
[Normal ... Style]) still translate. Formatted tips around a name are
translated as templates only (panels::label_or_name, library_name).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:36:44 +07:00
bflatasticandClaude Opus 5.5 cf7a43d786 i18n: dialogs show user, file and system names as they are
Lists and headings that mix our labels with names no longer send the
names through the catalog, so a preset, printer or plug-in called
"Regular" or "Black" keeps its name in every language.

- dialogs::shown_names / mixed_dropdown: translate only a list's built-in
  entries and show the rest with widgets::dropdown_names (same indices).
- Artboard lists (Save for Office, Save for Web, save options) and
  plug-in choices: dropdown_names.
- Mixed lists translate only their built-in entries: Recolor's Limit to
  Library, Save for Web, Print, Save PDF and Export for Screens presets,
  printers (Default Printer, PDF File), colour profiles and flattener
  presets (profile_pick, flatten::preset_dropdown), perspective presets,
  New Document's preset cards and Size, and the shortcut set.
- Plug-in dialogs and plug-in effects are headed by the plug-in's name as
  it is; plug-in parameter labels are not translated.
- The confirm dialog shows its message and detail as given, and the
  shared frame no longer translates headings again: each heading is
  translated by its spec. File > Revert translates its template before
  filling in the document's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:36:44 +07:00
bflatasticandClaude Opus 5.5 f4181890bd i18n review follow-ups: names are never translated, palette cache, locale probe off the UI thread
Follow-ups from the review of #205.

- Names that are user, file or system data are shown as they are: menu
  items for recent files, saved views, recent fonts and Type > Font,
  user swatch and graphic style libraries, saved perspective presets,
  custom workspaces (and Reset <name>) and plug-ins no longer go through
  the catalog (menus::shows_a_name). widgets::dropdown_names,
  menu_item_name and dim_name are the non-translating forms of the shared
  widgets, for the panels and dialogs that list such names.
- The command palette builds its entries (shown label, search text) once
  per language and plug-in set instead of translating ~700 labels every
  frame while a query is typed.
- The locale probe (reg.exe / defaults) runs on a background thread at
  startup instead of on the first frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:20:35 +07:00
BFlat 116ff3eee7 Merge pull request #205 from WildfootW/claude/laughing-pasteur-5d7kc7
i18n: UI localisation framework and a Traditional Chinese (zh-hant) catalog
2026-10-07 07:49:14 +07:00
WildfootWandClaude Fable 5.1 902c3ae8c1 i18n: UI localisation framework and a Traditional Chinese (zh-hant) catalog
Strings in code stay English and are the lookup keys; `crates/ui-egui/src/i18n`
maps them to display text at render time from one `*.tsv` catalog per language
(`LANGUAGES` registry, `tl!`/`t`/`tr`/`tr_ctx`/`tr_id`/`tn`/`fmt`). Command ids,
menu paths, `ui.menu.list`, the control channel, the CLI, MCP, status messages
and undo labels never see translated text, so agents, scripts and the existing
tests are unaffected (under `cargo test` the UI is always English).

Wired: in-window menus and the macOS native menu (rebuilt on a language change),
dynamic labels (Undo X, Reset X, Last Effect: X as templates), the command
palette (matches English and translated text), dock tabs, panel tooltips, the
toolbar (category names now cut by characters, not bytes), the dialog frame, the
shared widgets, the Preferences dialog, the shortcut editor, the hint and status
bars, the Home screen, and the literals of every panel and dialog.

Preference `interfaceLanguage` (User Interface › Language): `auto` or a code.
`auto` follows `VECTORCRAFT_LOCALE`, `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE`,
the macOS preferred languages and, on Windows (which sets no `LANG`), the user
locale from `HKCU\Control Panel\International`. `zh-TW`/`zh-HK`/`zh-MO`/
`zh-Hant-*` resolve to `zh-hant`; Simplified locales stay English until a
`zh-hans` catalog exists.

`zh-hant.tsv` (~2,850 rows) is clean-room, in the vocabulary used in Taiwan;
terms shared with PhotoCraft reuse its wording, vector terms use everyday
vector-drawing vocabulary. Tests: catalog well-formedness (placeholders,
ellipses, command ids, no Simplified characters), locale resolution, and two
coverage tests that fail when a menu string, panel/tool/preference label,
dialog button, hint-bar fragment or `tl!` literal has no row in a language
marked `complete_menus`. `VECTORCRAFT_I18N_DUMP=file cargo test -p
vectorcraft-ui-egui dump_source_strings` lists the strings for translators.

Also: two clippy findings under rustc 1.97 fixed in passing.

Integrated with main (squashed onto it, 42 commits on): the Czech interface
(#213) moves from its Rust table to `i18n/cs.tsv` on the registry (all 556
rows, Czech plural rule, its menu-coverage and glyph tests kept); Find Font's
resolution notes and the canvas context menu's labels are translated and
covered by the catalog tests (`menus::CONTEXT_LABELS`); the glyph test checks
CJK text only when the craft-fonts build input is embedded (#220 moved the
Japanese fonts out of this repository); a language saved by an older version
carries over to `interfaceLanguage`; Windows' locale lookup runs reg.exe by its
full path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:37:05 +07:00
BFlat 2507ed9dd9 Merge pull request #216 from fahrim/mcp-protocol-surface
MCP: prompts, resource templates, completions and logging
2026-10-07 07:35:21 +07:00
Fahri Meral e80b7e383b MCP: prompts, resource templates, completions and logging
The server answered tools/* and two fixed resources and nothing else, and
resources/templates/list returned an empty array. This adds the rest of the surface a
client can actually use, against 2025-06-18.

- prompts/list and prompts/get: five workflows (poster, icon-set, recolor,
  trace-and-style, export-set) with templated arguments. A missing required argument
  is -32602 and names itself.
- resources/templates/list, and resources/read for the templates: object/{id},
  command/{id}, effect/{id}, swatch/{name}. Reading one thing at a time instead of
  vectorcraft://document/json, which on a real document is thousands of lines an agent
  pays for again on every change.
- completion/complete for prompt arguments and template variables, ranked prefix
  first, capped at 100 with total and hasMore. The values are read live, so a suggested
  format, effect id, command id, trace preset, swatch or object id is one the engine
  has.
- logging/setLevel and notifications/message. Nothing installed a log::Log before, so
  the engine's log::debug! calls went nowhere; this is the sink, silent until asked.
  Installing it is vectorcraft-cli's job rather than the library's, so an embedder
  that already has a logger keeps it.

docs/mcp.md gains a Protocol section with the capability table and a Not implemented
section: subscriptions, progress, cancellation, sampling, elicitation and Streamable
HTTP all need the transport to interleave reads, which this one does not — handle_line
answers a line with a reply, so it neither reads an incoming cancellation nor can write
progress before the reply it belongs to.

62 tests (50 unit, 12 property/fuzz). protocol_props.rs gained five: prompts survive
junk arguments, completion/complete never panics and always answers at most 100
string values with a consistent hasMore, every template reads or fails cleanly, and
prompts/completions/templates are swept with junk params while asserting the document
comes back byte-identical.

ROADMAP: one bullet under Automation in Shipped so far and the changelog line. The
parity table and the gap list are untouched — MCP is agent automation, so feature
parity does not move.
2026-10-07 07:25:02 +07:00
BFlat 597b651bb6 Merge pull request #233 from theironchef/fix/canvas-edge-click
Canvas: a click just outside the canvas doesn't reach the tools
2026-10-07 07:24:21 +07:00
BFlat 945f8db359 Merge pull request #237 from theironchef/fix/flyout-panel-width
Panels: a flyout wider than 256 points grows towards the canvas
2026-10-07 07:24:13 +07:00
BFlat e50f02e638 Merge pull request #239 from theironchef/fix/dialog-width
Dialogs: the button row is as wide as the fields, not the window
2026-10-07 07:24:06 +07:00
BFlat aeaece9728 Merge pull request #240 from storytold/fix/font-test-installed-fonts
Tests: the font resolution test no longer depends on the installed fonts
2026-10-07 07:24:01 +07:00
bflatasticandClaude Opus 5.5 c6c2c7d0fe Tests: the font resolution test no longer depends on the installed fonts
list_says_how_each_font_resolved expected Source Sans 3 Black to be a
substitute, which holds only while the bundled family is the only Source
Sans 3: on a machine with the full family installed (it ships with many
font packs), Black resolves exactly and the test failed. It now asks for
a style no Source Sans 3 has, so the substitute path is tested anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:17:36 +07:00
theironchefandClaude Opus 5.5 61cd4ed6e6 Dialogs: the button row is as wide as the fields, not the window
The OK/Cancel row was laid out right to left at the full available
width, and a dialog without a maximum width may use the whole window:
the row, and the window with it, spanned the window (Clean Up, Blend
Options, every generic parameter dialog). The row now spans the fields
above it, at least the dialog's minimum width, so a dialog is sized to
its content.

Fixes #238.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:38:09 -07:00
theironchefandClaude Opus 5.5 0e1a5b3e45 Panels: a flyout wider than 256 points grows towards the canvas
The icon panel flyout was placed by its left edge, 262 points left of
the icon column, at a width of 256: a panel whose content is wider
(Separations Preview, Align, Transform, Stroke, Attributes, Gradient)
grew to the right, over the panel icons. It is now pinned by its right
edge, 6 points left of the column, so a wider panel grows towards the
canvas; panels of the usual width sit where they did.

Fixes #236.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:22:27 -07:00
Brandon Thomas b05e575eb8 Merge pull request #235 from storytold/docs/freebsd-platform
README: FreeBSD in the platform line and badge
2026-10-06 18:24:43 -04:00
Brandon ThomasandClaude Opus 5.5 1012361773 README: FreeBSD in the platform line and badge (build, test and release package are green)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:24:32 -04:00
Brandon Thomas f2a2671933 Merge pull request #128 from storytold/pr/freebsd-release
FreeBSD: release package and a packaging check in CI
2026-10-06 18:24:00 -04:00
theironchefandClaude Opus 5.5 10fafed6c4 Canvas: a click just outside the canvas doesn't reach the tools
egui counts a press a few pixels outside the canvas as hovering it (its
interaction radius), but the canvas only takes pointer positions inside
its rect, so such a press fell back to the canvas's centre: a click
just below the document tabs or beside the panels selected, typed or
drew in the middle of the view. Presses, and middle-button pans, now
start only on the canvas itself.

Fixes #232.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:10:50 -07:00
Brandon Thomas aafd565b99 Merge pull request #228 from EthanSuquet/fix/hide-dot-system-fonts
Fonts: the font menus leave out macOS's hidden "." families
2026-10-06 15:57:48 -04:00
Brandon Thomas 571881a830 Merge pull request #213 from petrleocompel/i18n/czech
i18n: Czech interface language
2026-10-06 15:50:25 -04:00
Brandon ThomasandClaude Opus 5.5 347369187f Merge main into #213 (after #207, #208, #209): one ROADMAP line for Japanese, Czech and vertical type
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:49:57 -04:00
Brandon Thomas d899a63286 Merge pull request #208 from imachus/pr/ja-vertical
Type: kinsoku, tate-chu-yoko and upright placement for vertical type
2026-10-06 15:49:07 -04:00
Brandon ThomasandClaude Opus 5.5 f95b032c82 Merge main into #208 (after #207 and #209): keep both IME and vertical-arrow tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:44:50 -04:00
Brandon Thomas ce9f3aa7c6 Merge pull request #207 from imachus/pr/ja-ime
Type: input method composition (Japanese IME) in the Type tool
2026-10-06 15:44:32 -04:00
Brandon Thomas ef3fa1689f Merge pull request #209 from imachus/pr/ja-fonts
Type: find fonts by any of their names; say how each one resolved
2026-10-06 15:44:17 -04:00
Brandon ThomasandClaude Opus 5.5 68352dab02 fmt: the font fallback path list fits on one line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:37:22 -04:00
Brandon ThomasandClaude Opus 5.5 51a9b899c1 Merge main into #209; leave other apps' private font folders alone
Conflicts with #214 (fonts by PostScript name): this branch's name index (FaceKeys,
aliases) stays, and #214's `by_postscript_name` (used by PDF import) reads a PostScript
map filled from it, keyed by the same normalized names.

Removed: scanning ~/Library/Application Support/Adobe (CoreSync/plugins/livetype and
Fonts), and the preference that switched it (Type › Include Fonts of Other Design Apps'
Folders). Fonts activated through another vendor's subscription are licensed for use
through that vendor's own sync; reading their files from VectorCraft would go around that
licence, and the project's clean-room rules keep VectorCraft out of other vendors' files.
Fonts the user has installed in the system or user font folders are still found by every
name, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:36:09 -04:00
BFlat 81e7b915af Merge pull request #229 from storytold/fix/issue-219
Fix #219: right-click on the canvas opens a context menu
2026-10-07 02:34:33 +07:00
EthanSuquetandClaude Opus 5.5 fcc9414480 Fonts: the font menus leave out macOS's hidden "." families
macOS keeps faces for its own interface under family names that start with
"." (".SF NS", ".LastResort", ".Aqua Kana"…); Mac apps don't list them. Since
the font menus list every installed family (#36), they showed 74 of them on a
stock macOS, at the top of every list.

- FontDb::menu_family_list: family_list without names that start with ".",
  cached like it. Only a leading dot hides a family.
- Type › Font, the family dropdowns (Character, Properties), text.fontList and
  Refresh Font List's count use it.
- Lookups by name still see every family: documents and fallbacks that name
  a hidden family still find it (face, has_family, find_family, families,
  text.fontList {family}, Fonts in Document's missing check).
- Test: tests_sysfonts::font_lists_leave_out_hidden_system_families_which_still_resolve
  (a temporary font folder); the engine and UI font-list tests compare with
  the listed families.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:33:21 -04:00
Brandon Thomas 377204f90e Merge pull request #225 from theironchef/fix/shift-drag-selected
Selection tool: Shift+drag on a selected object moves it, constrained
2026-10-06 15:30:18 -04:00
Brandon Thomas 0306d51592 Merge pull request #214 from p0lyg0n/fix/pdf-font-postscript-names
PDF/.ai import: fonts are found by their PostScript name, whatever hyphens the family holds
2026-10-06 15:30:14 -04:00
Brandon Thomas ebc6d1af7b Merge pull request #181 from japbcoelho/fix/cli-closed-stdout
CLI: a closed stdout ends vectorcraft-cli quietly
2026-10-06 15:30:09 -04:00
bflatasticandClaude Opus 5.5 cdb734aede Fix #219: right-click on the canvas opens a context menu
Right-clicking the canvas now opens a context menu, as the reference app
does. The object under the pointer is selected first unless it already is.
The menu then lists what applies to the selection: Undo and Redo, Cut, Copy,
Paste, Isolate Selected Group or Exit Isolation Mode, Group and Ungroup,
Join and Average, Make or Release Clipping Mask and Compound Path, Make
Guides, the Transform, Arrange and Select submenus, and Export Selection.
On empty canvas it lists Paste, the zoom commands, the ruler, grid and guide
toggles and Select All. Commands that can't run now are left out (Undo and
Redo stay, greyed out), and a press on the canvas only closes the menu. Agents list the same menu with the
`ui.contextMenu.list` control method.

Root cause: the canvas ignored the secondary button except in the toolbar's
tool flyouts; there was no context menu at all. The menu reuses the menu
bar's items, rendering and dispatch; Object > Transform and Arrange are now
shared lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:28:52 +07:00
BFlat 2f743f155f Merge pull request #227 from storytold/fix/issue-221
Fix #221: Effect > Pathfinder works on several selected objects
2026-10-07 02:27:53 +07:00
bflatasticandClaude Opus 5.5 4bc0837867 Fix #221: Effect > Pathfinder works on several selected objects
Choosing an Effect > Pathfinder item (Add, Intersect, Exclude, Subtract...)
with several loose objects selected now groups them and applies the live
effect to the group, in one undo step, so the result shows at once and stays
editable. A selection that already holds a group, or a single object, works
as before, and agents get `grouped: true` back when it happens.

Root cause: Pathfinder effects combine the contents of a group or layer.
Applied to loose objects, each one got an effect with nothing to combine, so
nothing visibly changed, while the Pathfinder panel's commands (found by the
command search) act on the selection directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:21:06 +07:00
BFlat 2ba0e1f6e5 Merge pull request #226 from storytold/fix/issue-177
Fix #177: DXF Fit and Aligned text span their two points
2026-10-07 02:20:43 +07:00
theironchefandClaude Opus 5.5 80eb5844c7 Selection tool: Shift+drag on a selected object moves it, constrained
Pressing with Shift on an object that was already selected toggled it
out of the selection at once and went idle, so the drag that followed
moved nothing. The toggle now waits for the release: a Shift-click
still takes the object out of the selection, while a Shift-drag moves
the selection constrained, as it already did for unselected objects
and when Shift was pressed mid-drag.

Fixes #224.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-06 12:16:02 -07:00
bflatasticandClaude Opus 5.5 82be706b94 Fix #177: DXF Fit and Aligned text span their two points
DXF text justified Fit now keeps its height and stretches or squeezes
horizontally to run exactly from its first point to its second; Aligned
text scales as a whole, height included, to do the same, its width factor
kept. Attributes justified the same way follow suit. Without a second point,
or with both points the same, the text keeps its size as before.

Root cause: the importer took the angle of the two points and anchored the
text at the first, but kept the text's height and width factor, ignoring the
distance between the points. The text now records that span and, when it
becomes a text object, is measured in the font it is set in, then given the
horizontal scale (Fit) or size (Aligned) that makes it end at the second
point, kept between 1% and 10,000% of its natural length. The import's
bounds estimate uses the same scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:14:39 +07:00
BFlat 5af667182a Merge pull request #223 from storytold/fix/issue-206
Fix #206: Live Paint treats every path as an edge, open lines included
2026-10-07 02:14:17 +07:00
bflatasticandClaude Opus 5.5 700b6786c4 Fix #206: Live Paint treats every path as an edge, open lines included
The Live Paint Bucket now fills areas enclosed by any paths. Crossing
lines enclose faces it can fill, a line across a shape splits that shape's
face in two, and an area enclosed by paths becomes a face even when nothing
fills it (it starts unfilled). Edges are the paths cut wherever another path
meets them, keeping their stroke, so Shift-click paints each piece. Closed
shapes give the same faces as before.

Root cause: Live Paint built its faces from the Pathfinder regions, which
divide the filled areas of closed shapes. An open path counted only as the
area it would fill if closed, which for a straight line is nothing, so lines
divided nothing and the bucket found no face to fill. A new planar map in
vectorcraft-pathops (live_paint) sweeps every path as an edge: an open path
goes in traced there and back, with crossing counts that never cancel; faces
are walked on the resulting graph (holes included), and their fills and the
edges' sources are found geometrically. The sweep runs in an exactly sheared
frame, because it mishandles a horizontal line traced there and back.
Make, Merge, the bucket and Live Paint Selection all use it;
interior_point moves to vectorcraft-pathops with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:08:39 +07:00
Brandon ThomasandClaude Opus 5.5 77159bb08e FreeBSD: the release and package jobs build with craft-fonts, like the other platforms
Since 546a65f every release job checks storytold/craft-fonts out at its pinned commit and
builds with CRAFT_FONTS_DIR and CRAFT_FONTS_REQUIRED=1. The FreeBSD jobs do the same inside
the VM, so FreeBSD packages carry the Japanese fonts and their OFL texts. The package check
now requires each expected file (the font licences included) instead of any one of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:41:41 -04:00
Brandon Thomas 047a3bfc6f Merge remote-tracking branch 'origin/main' into pr/freebsd-release 2026-10-06 14:41:07 -04:00
Brandon Thomas 856e36c694 Merge pull request #220 from storytold/pr/craft-fonts
Fonts: use craft-fonts as an optional build input; move the Japanese fonts out
2026-10-06 14:37:41 -04:00
Brandon ThomasandClaude Opus 5.5 721d1118f3 Fonts: docs use an absolute CRAFT_FONTS_DIR; note the web size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:19:24 -04:00
Brandon ThomasandClaude Opus 5.5 3491b99321 Fonts: the Japanese-menu glyph test skips without craft-fonts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:09:46 -04:00
Brandon ThomasandClaude Opus 5.5 546a65f77a Fonts: use craft-fonts as an optional build input; move the Japanese fonts out
Shippori Mincho (assets/fonts, from #145) moves to storytold/craft-fonts. crates/text/build.rs
embeds the fonts of a craft-fonts checkout when CRAFT_FONTS_DIR is set (web: BIZ UDPGothic
Regular only) as vectorcraft_text::CRAFT_FONTS; unset, it is empty and Japanese falls back to the
installed fonts. Document text gets the Japanese faces after the bundled fonts (Mincho first); the
UI adds them at the end of every egui family (BIZ UDPGothic first), before ui_fonts' system-font
discovery. Release and FreeBSD CI builds check craft-fonts out at a pinned commit; packages carry
the fonts' OFL texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:42:21 -04:00
Brandon Thomas 77708ad8de Merge pull request #215 from storytold/pr/windows-arm64
Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
2026-10-06 10:13:23 -04:00
Brandon ThomasandClaude Opus 5.5 129c9ca61e Windows ARM64 workflow: pin third-party actions by commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:04 -04:00
Brandon ThomasandClaude Opus 5.5 ceb6defcf3 Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
Release builds add an arm64 MSI and portable zip, cross-compiled on the x64
runner (signing and WiX as for x64). package.ps1 checks each binary's PE
header before packaging: Machine must match -Arch, the app must be a GUI
program and the CLI a console program. It skips the CLI smoke run when an
ARM64 build can't run on the host. A new Windows ARM64 workflow packages the
ARM64 MSI, installs it on a Windows 11 ARM64 runner, checks both installed
exes are ARM64, runs the CLI, and uninstalls. Ported from printcraft#66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:38:29 -04:00
imachusandClaude Opus 5.5 ab6a668aa9 Type: kinsoku, tate-chu-yoko and upright placement for vertical type
Builds on the vertical type from #145 (same data, commands and tools).

- Kinsoku (the strict set, horizontal Japanese too): no line starts with a
  closing bracket, 、。, a middle dot, colon, !?, the long vowel mark,
  iteration marks or small kana; no line ends with an opening bracket.
  Hyphenation breaks obey it too.
- Tate-chu-yoko: a run of two or three half-width digits (10月, 第100回)
  stands upright across the column as one block one em long, squeezed to
  the em when wider; longer numbers stay on their side.
- Upright or on its side by Unicode's Vertical_Orientation: CJK and
  symbols upright, marks that need a vertical form on their side when the
  font has none; upright glyphs advance at least one em, centred, and turn
  about the ideographic em box centre so they sit on the column's centre
  line. Point type keeps its anchor on the first column's centre line.
- `vrt2` is no longer requested: its glyphs are already on their side, so
  with the layout turning Latin and digits too they came out upside down
  (and tate-chu-yoko blocks broke) in fonts that have it, e.g. Hiragino.
- The line→text mapping is one affine (`TextLayout::line_xf`, an exact
  quarter turn plus the point-type offset); `logical_point` and
  `physical_point` use it. Pixel snapping skips vertical type.
- Tests: kinsoku in both directions, tate-chu-yoko blocks, upright vs
  sideways, anchor on the centre line, arrows along columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:02:38 +09:00
imachusandClaude Opus 5.5 8dc2fd077f Type: find fonts by any of their names; say how each one resolved
Documents made elsewhere often name a font differently from the family
name VectorCraft lists: a localized family name (ヒラギノ角ゴシック), a
legacy family/style pair (Hiragino Sans + W3) or a PostScript name
(HiraginoSans-W3). FontDb now indexes every name in a face's name table
and resolves a request to the right face, telling the caller whether it
matched exactly, by another name, by substitution or not at all
(FontMatch).

- text: FaceKeys/aliases, FontDb::resolve, canonical, find_postscript;
  fix a race where a face loaded by another thread was reported missing.
- engine: text.fonts reports status, the resolved face and missing
  glyphs; Replace Font resolves the same way.
- render: substituted-font highlighting uses resolve, so a font found
  by another name is no longer shown as missing.
- Find Font notes how each font was found.
- Preferences › Type › Include Fonts of Other Design Apps' Folders
  (macOS, on by default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:45:10 +09:00
imachusandClaude Opus 5.5 66fa0a998a Type: input method composition (Japanese IME) in the Type tool
Typing with an input method (the macOS Japanese IME and the like) used to
insert the raw keys (romaji came in as ASCII): egui's Ime events weren't
handled.

- Tools gain `ime_preedit` / `composing` / `ime_caret`; the Type tool lays
  the marked text out in place inside its typing session, underlined (the
  clause being converted thicker), and commits it through `text_input`.
  Character ranges from the IME convert to byte ranges in one place. A
  cancelled composition leaves no empty undo step; an empty clear with
  nothing marked never deletes the selection.
- The UI sends Text and Ime events to the tool in order, sends IMEOutput
  every frame while text is edited (what lets egui-winit enable the IME
  and place its candidate window at the caret, or the converting clause),
  and holds back keys, Enter/Escape, clipboard, single-key shortcuts and
  menu commands (Undo, Cut, Select All… also from the native menu) while
  composing. A click, a tool switch or a style change ends the
  composition with the marked text kept as typed; on macOS the IME is
  told to discard it (NSTextInputContext.discardMarkedText) so it doesn't
  come back in the next input.
- Undo during a typing session also ends the Type tool's session, so its
  caret and marked text follow the document.
- Tests: tools (char → byte ranges, underlines and candidate position),
  engine (compose → commit is one step, cancel leaves no step, a click
  commits, Undo mid-composition is safe), ui-egui (synthetic Ime events:
  compose, commit, ⌘Z held back, IME output, click and app switch).

Checked by hand on macOS 27 with the Japanese IME (romaji input, convert,
commit, cancel, Undo/Redo after commit, click away, Latin mode, app
switch). Not checked: Windows and Linux IMEs, the web build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:41:50 +09:00
Petr Leo CompelandClaude Opus 5.5 b045390311 i18n: Czech interface language
VectorCraft ▸ Language gains Čeština (`app.language {lang: "cs"}`,
persisted as "cs"). `Language::tr` now picks a table per language.

The Czech table (crates/ui-egui/src/i18n/czech.rs) translates every
menu title, submenu, item and header in the menu tree, plus the labels
toggling items switch to (Show/Hide Guides, Lock/Unlock Grid, Outline/
Preview, Edit Contents/Edit Envelope…). It was written clean-room from the
meaning of the English labels: commands in the infinitive, menus as nouns.
Left as-is on purpose: the product name, language names, OpenType, the
built-in workspace names and the perspective grid preset names.

Tests:
- every Czech (and Japanese) entry is a menu label, toggled labels included;
- Czech covers every menu label except fonts, sizes, plug-in names and
  an explicit KEEP_AS_IS list;
- ellipses match their source label, entries are unique;
- the Language menu lists every language; `cs` validates and persists;
- each UI font family's own first font has the Czech letters and
  punctuation (no system fonts needed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 14:23:11 +02:00
Brandon Thomas 7a39cb7b89 Merge pull request #212 from storytold/pr/release-0.3.1
Release: VectorCraft v0.3.1
2026-10-06 08:03:26 -04:00
Brandon ThomasandClaude Opus 5.5 15ab25426e Release: VectorCraft v0.3.1
Fixes the Windows installer: the Start Menu shortcut and Add/Remove Programs icon were blank. Release builds no longer open a console window behind the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:03:13 -04:00
Brandon Thomas 899a82d31d Merge pull request #211 from storytold/pr/windows-packaging
Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
2026-10-06 07:53:56 -04:00
Brandon ThomasandClaude Opus 5.5 7bca89dd15 Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
The MSI Icon id had no .ico/.exe extension, which Windows Installer needs for
advertised shortcuts and ARPPRODUCTICON, so both showed a blank icon. Release
builds of the desktop app are now GUI-subsystem (no console window; the CLI stays
a console program), the shortcut/summary description names what VectorCraft does, and
packaging-lint rejects an Icon id without an extension. Found in printcraft#57.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:50:24 -04:00
Brandon Thomas 7f5344a60c Merge pull request #145 from midasdf/feat/japanese-interface-language
Add Japanese menus and initial vertical type tools
2026-10-06 07:43:47 -04:00
Brandon ThomasandClaude Opus 5.5 691a54e7fd Roadmap and README: Japanese interface and initial vertical type
The appended Interface localization section moves into the roadmap's structure: a
Shipped so far entry, the Type advanced row (45%, CJK composition left), the gap list,
the inventories (16 stubbed menu items; Touch Type the only missing tool) and totals
recomputed from the parity table (75.3%, 211–332 h to feature parity); README Status
follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:43:25 -04:00
Brandon ThomasandClaude Opus 5.5 1733fa5fdb i18n: keep only VectorCraft's own menu labels, and test that
47 of the 83 entries were labels from other apps ("New blank PDF", "Comments panel",
"Read mode"…) that never appear here. A test now walks the menu tree and fails on any
entry that isn't one of VectorCraft's labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:43:25 -04:00
Brandon Thomas 7715dcd285 Merge remote-tracking branch 'origin/main' into pr145-v2 2026-10-06 07:43:14 -04:00
Hiroshi InukaiandClaude Opus 5.5 caba979b01 PDF/.ai import: fonts are found by their PostScript name, whatever hyphens the family holds
A document names its fonts by PostScript name, which the importer split at the
first hyphen into family and style. A family with hyphens in its name was never
found: "Rounded-X-Mplus-1c-black" became family "rounded", style
"x-mplus-1c-black", and the text fell back to another font. Common Japanese
families have hyphens in their names.

The system font scan now also indexes each installed face's PostScript name
(read from the same name table it already reads). The importer asks for the face
with that exact name first (`FontDb::by_postscript_name`) and splits the name only
when no installed face has it.

Test: installed faces are found by PostScript name, in any case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:18:53 +09:00
Brandon Thomas 025315f4bf Merge pull request #210 from storytold/fix/clippy-byte-str
clippy: byte string in the envelope test (main's clippy step is red on Rust 1.99)
2026-10-06 07:09:43 -04:00
Brandon ThomasandClaude Opus 5.5 4c6ac00e72 clippy: a byte string for the envelope test's newline (byte_char_slices, red on main with Rust 1.99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:09:35 -04:00
midasdf 44b2b984ed i18n: add Japanese menus and initial vertical type tools 2026-10-06 16:55:47 +09:00
BFlat 2e2bb33909 Merge pull request #203 from storytold/m8-envelope-editing
M8.17: Edit envelopes on the canvas (M8 done)
2026-10-06 12:23:30 +07:00
bflatasticandClaude Opus 5.5 5bb273487e ROADMAP: M8 done (M8.1-M8.20), parity re-derived
The M8 row is done with what the fidelity pass delivered and what is left; the
Symbols/blends/envelopes/perspective parity row moves from 75% to 88%; feature
parity ~75%; Shipped so far and the README status follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:23:20 +07:00
bflatasticandClaude Opus 5.5 695c541f0d M8.17: Edit envelopes on the canvas: Edit Contents, mesh points and handles, Control bar
- Edit Contents: while an envelope's contents are edited, clicks hit the
  content where it sits undistorted and the Selection tool selects it; the
  menu item reads Edit Envelope then. No default shortcut: the reference
  app's (Shift+Cmd+V) is Paste in Place here.
- A selected envelope shows its mesh (a warp's surface grid, a mesh's own,
  a top object's outline) instead of its content's outlines.
- Mesh envelope points get bezier handles (append-only; none stored is the
  smooth Catmull-Rom mesh, which the first handle edit fills in exactly
  along the grid lines). setMeshPoint takes `handle`; object.mesh.movePoint,
  addLine and deletePoint edit mesh envelopes too. The Mesh tool and Direct
  Selection drag points and the clicked point's handles (shared MeshEdit);
  the Mesh tool adds rows and columns inside mesh envelopes. Reset with
  Mesh keeps the shape along the grid with handles; Release returns the
  exact mesh.
- Control bar: Edit Envelope / Edit Contents, warp style, orientation,
  bend and distortions or mesh rows and columns, Reset, Envelope Options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:16:59 +07:00
BFlat 254251f23a Merge pull request #201 from storytold/m8-perspective-widgets
M8.11: Perspective grid widgets: ground-level points move the grid, separate extents, cell size widget, screen-fixed Plane Switching Widget with 1-4 keys
2026-10-06 12:16:50 +07:00
bflatasticandClaude Opus 5.5 99e3aaa798 ROADMAP: M8.11 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:16:40 +07:00
bflatasticandClaude Opus 5.5 0889f13cd8 M8.11: Perspective grid widgets: ground-level points move the grid, separate extents, cell size widget, screen-fixed Plane Switching Widget with 1-4 keys
The Perspective Grid tool's left and right ground-level points move the whole
grid; the left and right extents are separate (extentRight; Alt drags both,
Shift goes by whole cells); a cell size widget sits up the line where the planes
meet; Shift keeps a vanishing point on the horizon and the ground points and
origin on one axis. The Plane Switching Widget stays put in a corner of the
document window (ViewInfo/ToolContext carry the window as a ScreenFrame), takes
its clicks with any tool while the grid shows (the Perspective Selection tool
too while it's hidden), and 1-4 pick the left, horizontal, right and no plane.
Double-clicking the Perspective Grid tool opens Perspective Grid Options
(show the widget, its corner; perspective.widget.options, kept with the
preferences).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:16:39 +07:00
BFlat 565b75b91d Merge pull request #200 from storytold/m8-perspective-type-symbols
M8.14: Type and symbols in perspective are really projected and stay editable; attachments survive copies
2026-10-06 12:10:14 +07:00
bflatasticandClaude Opus 5.5 3280586f4c ROADMAP: M8.14 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:10:02 +07:00
bflatasticandClaude Opus 5.5 8255d606ae M8.14: Type and symbols in perspective are really projected and stay editable; attachments survive copies
Type and symbol instances attached to a plane keep their art and carry a projection
(Node.perspective.projection, append-only); the canvas and every export draw their outlines
through it (reshape/bake), so they are foreshortened and stay type and symbol instances. Bounds,
hit testing and transforms follow the projection. Object › Perspective › Edit Text
(perspective.editText, or double-clicking with the Perspective Selection tool) shows the type flat
where it is drawn in isolation mode; exiting isolation projects it again. Release with
Perspective keeps their look. Copies, pastes, duplicates and Selection-tool moves keep the
attachment (the art moves as dragged). Flare, spiral and polar grid draw on the active plane, and
so do the click-to-size shape dialogs (perspective.draw `at`: sizes in plane units).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:10:00 +07:00
BFlat 5a13626faa Merge pull request #199 from storytold/m8-envelope-distort-all
M8.16: Envelopes bend images, symbols, gradients, patterns and appearance; warps keep their frame
2026-10-06 12:01:57 +07:00
bflatasticandClaude Opus 5.5 2967c3a9ba ROADMAP: M8.16 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:48 +07:00
bflatasticandClaude Opus 5.5 04fa49413c M8.16: Envelopes bend images, symbols, gradients, patterns and appearance; warps keep their frame
- Images distort as a raster mesh warp: cut by Fidelity into triangles,
  each a clipped image under the affine map of its corners (pieces overlap
  slightly, no seams; the renderer paints them without clip layers; SVG
  writes the pixels once and <use>s them).
- Symbol instances distort as their symbol's art (the renderer's cache
  follows edits to the symbol).
- Distort Appearance bakes strokes (filled outlines) and geometry effects
  before bending; Distort Linear Gradients bends linear gradients; Distort
  Pattern Fills bends the tiles. Opacity masks of the content bend too.
- Bug fix: a rotated, sheared or reflected warp envelope re-warped along
  the page axes. Envelopes now keep their own frame (append-only model
  field), so the warp turns with the art; move and scale keep it square.
- Live evaluation shares one set of hooks (vectorcraft_doc::live::Hooks,
  vectorcraft_effects::{expand_live, expanded_live_group,
  expand_live_deep}) between the canvas, Expand and every exporter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:47 +07:00
BFlat cd99714b42 Merge pull request #198 from storytold/m8-blend-fidelity
M8.20: blends interpolate strokes, gradients, groups, type and symbols faithfully
2026-10-06 12:01:39 +07:00
bflatasticandClaude Opus 5.5 e1225f4c24 ROADMAP: M8.20 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:30 +07:00
bflatasticandClaude Opus 5.5 98a3e15ec3 M8.20: blends interpolate strokes, gradients, groups, type and symbols faithfully
Closed shapes start where they twist least (unless the Blend tool
picked the anchors); dashes, miter limit, arrowhead scale and width
profiles interpolate (caps, joins and brushes switch halfway);
gradients with different stop counts resample to common stops; groups
with different member counts grow the extra members out of a point;
compound steps stay compound paths; type, symbol instances and images
interpolate their transforms, type its colours and sizes run by run.
New blends are knockout groups and the canvas draws them knocked out
as the exports do. Expand and Release keep the blend's name,
transparency, opacity mask and appearance, and Object > Expand expands
blends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:28 +07:00
BFlat 55d7df36e4 Merge pull request #197 from storytold/m8-width-tool
M8.7: Width tool edits compound-path strokes, shows every subpath's points, own cursors
2026-10-06 12:01:20 +07:00
bflatasticandClaude Opus 5.5 fe2259536d ROADMAP: M8.7 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:11 +07:00
bflatasticandClaude Opus 5.5 13db157d36 M8.7: Width tool edits compound-path strokes, shows every subpath's points, own cursors
- A compound path's stroke lives on the compound: the tool edits it through
  any member, and stroke.widthPoint.* / stroke.widthProfile.set given a member
  edit the compound's profile.
- Width points show on every subpath the stroke runs along (not just the
  first) for the hovered and the selected stroke.
- Cursors: the Width tool's own glyphs off a stroke, over a stroke (a drag
  adds a point) and over a width point or handle (a drag moves or widens it).
- Hint-bar text for the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:10 +07:00
BFlat bd91cf9123 Merge pull request #196 from storytold/m8-liquify-behaviour
M8.4: Liquify keeps going while held, leaves non-paths alone, strokes apply incrementally
2026-10-06 12:01:02 +07:00
bflatasticandClaude Opus 5.5 5061404ec6 ROADMAP: M8.4 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:00:53 +07:00
bflatasticandClaude Opus 5.5 e9abc62f1a M8.4: Liquify keeps going while held, leaves non-paths alone, strokes apply incrementally
Holding the brush still keeps Twirl, Pucker and Bloat applying, scaled by the
time held: a new Tool::tick (time supplied by the host: canvas frames, or
`holdMs` on ui.pointer / pointer_gesture events) repeats the last point every
0.1 s, one more dab there. Liquify never touches graphs, meshes, type, symbols,
images, or envelopes, repeats and blends with their contents, nor guides in
selection mode; those under the brush come back in `skipped` with a `warning`
that the status bar shows (UiRequest::Status). A drag applies only the dabs
each new sample adds (the stroke's state is kept between previews), giving the
same result as the whole stroke, so journal replay still reproduces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:00:52 +07:00
BFlat fc0cb255a4 Merge pull request #195 from storytold/m8-perspective-view-options
M8.10: Perspective grid view options: Lock Grid, Lock Station Point, Snap to Grid, Show Rulers
2026-10-06 11:49:33 +07:00
bflatasticandClaude Opus 5.5 aa08c5bef7 ROADMAP: M8.10 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:49:22 +07:00
bflatasticandClaude Opus 5.5 e3b079de8c M8.10: Perspective grid view options: Lock Grid, Lock Station Point, Snap to Grid, Show Rulers
View > Perspective Grid gains Show/Hide Rulers, Snap to Grid (on by default),
Lock/Unlock Grid and Lock Station Point, with flipping labels and checkmarks (and
Show/Hide Grid's label). Lock Grid stops the grid widgets from dragging; Lock
Station Point swings the other vanishing point around the station point; Snap to
Grid lands drawn corners and moved edges on gridlines within a quarter cell
(perspective.draw/move take snap?); Show Rulers draws a ruler up the true height
line in the grid's units. Gridlines draw in the Define Grid colours and opacity.
New commands perspective.grid.lock/lockStation/snap/rulers (view state).

Existing tests that measure exact perspective moves (two in tests_distort.rs,
the persp_session helper of tests_persp_select.rs) turn snapping off first (it is on by
default and would move their exact corners onto gridlines).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:49:19 +07:00
BFlat 85e7acf138 Merge pull request #194 from storytold/m8-perspective-planes
M8.13: Perspective planes move along their normals with widgets, alone, with or copying their objects
2026-10-06 11:44:27 +07:00
bflatasticandClaude Opus 5.5 e774f15537 ROADMAP: M8.13 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:44:12 +07:00
bflatasticandClaude Opus 5.5 0f0dbbf9d5 M8.13: Perspective planes move along their normals with widgets, alone, with or copying their objects
The grid keeps per-plane offsets (leftOffset, rightOffset, groundOffset). Each plane has a widget
(a diamond in its colour) that the Perspective Grid and Perspective Selection tools drag: plain
moves the plane, Shift moves the objects on it too, Alt copies them (perspective.plane.move).
Double-clicking a widget, or ui.perspectivePlane, opens the plane's options dialog (location; Do
Not Move / Move All Objects / Copy All Objects). Object › Perspective › Move Plane to Match Object
(perspective.plane.matchObject). New objects attach where the plane is. Grid edits leave
attached art in place as the reference app does; perspective.grid.set {reproject: true} moves it
with the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:44:11 +07:00
BFlat 7ec936a0c1 Merge pull request #192 from storytold/m8-puppet-session
M8.6: Puppet Warp pins appear at once, warp from the rest shape and follow Undo
2026-10-06 11:36:27 +07:00
bflatasticandClaude Opus 5.5 b7c032df43 ROADMAP: M8.6 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:36:15 +07:00
bflatasticandClaude Opus 5.5 117aa0b5a3 M8.6: Puppet Warp pins appear at once, warp from the rest shape and follow Undo
- Pins show as soon as the tool has art selected: automatic ones at the
  centre and the middle of each limb's end (at least three), until pins are
  placed. They stay across tool switches while the selection is unchanged and
  a new selection starts afresh.
- The pins live in the document (Document.puppet, never saved) with the rest
  shape they started from: every warp starts from it, so warps don't stack and
  dragging a pin back restores the original; Undo/Redo take the pins back with
  the art. Adding and deleting pins are undo steps too.
- object.puppetWarp gains rest: true (the tool's pins); the new query
  object.puppetWarp.pins reads them. A click off the mesh adds nothing and
  says so (not-allowed cursor, canvas label); the command refuses such pins.
- The mesh grid covers the expanded art, so tiny art with a wide Expand no
  longer allocates millions of cells.
- Tests on groups, compound paths and open strokes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:16:10 +07:00
BFlat 47ef223a02 Merge pull request #191 from storytold/m8-blend-spine
M8.19: blend spine and keys editable on the canvas
2026-10-06 11:13:11 +07:00
bflatasticandClaude Opus 5.5 8752561132 ROADMAP: M8.19 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:13:01 +07:00
bflatasticandClaude Opus 5.5 3d35b8518f M8.19: blend spine and keys editable on the canvas
A selected blend shows its spine. Direct Selection drags spine points
and handles, the Pen tool adds and deletes points on a selected
blend's spine (object.blend.spine.moveAnchor/addAnchor/removeAnchor).
The straight spine becomes a path on the first edit, its points pinned
to the key objects (append-only BlendSpec.key_anchors): moving a key
moves its end of the spine and the other way round; spines from older
files and Replace Spine still spread the keys evenly. Direct and Group
Selection pick blend keys on the canvas, edits redraw the blend live,
and Release leaves the spine as a path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:12:58 +07:00
BFlat a5c2d6b682 Merge pull request #190 from storytold/m8-blend-options
M8.18: Blend Options dialog and the Blend tool's anchor targeting
2026-10-06 11:01:33 +07:00
bflatasticandClaude Opus 5.5 781f797b00 ROADMAP: M8.18 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:01:20 +07:00
bflatasticandClaude Opus 5.5 c5985b3f47 M8.18: Blend Options dialog and the Blend tool's anchor targeting
Blend Options is a dedicated dialog (Spacing dropdown with its value,
Align to Page / Align to Path buttons, live preview) opened on the
selected blend's own options, so OK no longer turns Smooth Color into
5 steps; with nothing selected it sets what new blends start with
(object.blend.info reads them). The Blend tool blends from clicked
anchor points (blend spec `starts`), adds keys to an existing blend
instead of nesting it, opens Blend Options on double-click, Alt-click
and its toolbar button, and has its own cursors and hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:01:18 +07:00
BFlat 5157dc87b8 Merge pull request #189 from storytold/m8-perspective-selection
M8.12: Perspective Selection tool scales, copies, moves perpendicular and nudges in perspective
2026-10-06 11:00:53 +07:00
bflatasticandClaude Opus 5.5 6210f2f15d ROADMAP: M8.12 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:00:28 +07:00
bflatasticandClaude Opus 5.5 a0847e8a25 M8.12: Perspective Selection tool scales, copies, moves perpendicular and nudges in perspective
The Perspective Selection tool shows the selection's bounding box in perspective; its handles
scale in plane space (perspective.transform; Shift proportional, Alt from the centre). Alt-drag
moves copies that stay attached, pressing 5 while dragging moves along the plane's normal
(ToolKey::Digit, delivered by the canvas and MCP press_key), and the arrow keys nudge in
perspective (perspective.nudge). Transform Again repeats the last perspective move or scale in
plane space. Attachments (plane and depth) now live on the objects (Node.perspective), so copies,
duplicates and pastes keep them; grids from older files hand theirs over. Homography moves to
vectorcraft-geom. Hint-bar text for the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:00:15 +07:00
BFlat 7df7cf568a Merge pull request #188 from storytold/m8-envelope-test-dates
M8.16: Envelope export test ignores the files' time stamps
2026-10-06 10:57:50 +07:00
bflatasticandClaude Opus 5.5 b062070642 ROADMAP: M8.16 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:57:36 +07:00
bflatasticandClaude Opus 5.5 08c70dc35d M8.16: Envelope export test ignores the files' time stamps
enveloped_type_exports_distorted_in_every_format compared two exports
byte for byte, and EPS dates its file to the second (%%CreationDate), so
it failed whenever the clock ticked between them. The comparison now drops
the CreationDate/ModDate lines and still checks repeatability and that the
bend shows in every format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:56:18 +07:00
BFlat 4a59a171e2 Merge pull request #187 from storytold/m8-perspective-presets
M8.9: Perspective grid presets: built-in views, Save Grid as Preset, presets manager
2026-10-06 10:52:02 +07:00
bflatasticandClaude Opus 5.5 8974957890 ROADMAP: M8.9 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:51:15 +07:00
bflatasticandClaude Opus 5.5 c3c4185cef M8.9: Perspective grid presets: built-in views, Save Grid as Preset, presets manager
View > Perspective Grid > One/Two/Three Point Perspective list the built-in views
([1P/2P/3P-Normal View], plus Low and High views generated in code) and the saved
presets of each type; Save Grid as Preset... and Define Grid's Preset menu and Save
Preset... save the fields. Edit > Perspective Grid Presets... (previously a
mislabelled reset to the two-point grid) manages them: New, Edit, Delete, Import,
Export (.vcperspective), stored with the preferences. New commands
perspective.presets.list/save/delete/export/import; perspective.grid.preset takes
a preset name. Preset import is fuzzed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:51:12 +07:00
BFlat 3d850a2864 Merge pull request #186 from storytold/m8-liquify-options
M8.3: Liquify Tool Options dialogs, pen pressure and Brush Affects
2026-10-06 10:50:50 +07:00
bflatasticandClaude Opus 5.5 aa5b544fca ROADMAP: M8.3 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:50:23 +07:00
bflatasticandClaude Opus 5.5 92cadd6083 M8.3: Liquify Tool Options dialogs, pen pressure and Brush Affects
Double-clicking a Liquify tool opens its Tool Options (dialog liquifyOptions):
the shared Global Brush Dimensions (Width, Height, Angle, Intensity, Use
Pressure Pen), the tool's options (Detail, Simplify with its checkbox, Twirl
Rate, Complexity, Brush Affects, Horizontal/Vertical) and Show Brush Size.
The canvas captures pen pressure from pen/touch force, ui.pointer and MCP
pointer events take `pressure`, and object.liquify takes [x, y, pressure].
Fixes: LiquifyParams::to_json now sends affectAnchors/In/Out; Alt-drag sizes
the brush from its current size (Shift keeps proportions) instead of
collapsing it. Hint-bar text for the seven tools. Registers the MCP Liquify
tests file added in M8.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:50:16 +07:00
BFlat 4950cc17cf Merge pull request #185 from storytold/m8-puppet-pins
M8.5: Puppet Warp pins rotate, multi-select and Select All; Control bar and hint
2026-10-06 10:49:38 +07:00
bflatasticandClaude Opus 5.5 c6e69fad33 ROADMAP: M8.5 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:48:12 +07:00
bflatasticandClaude Opus 5.5 67b2213957 M8.5: Puppet Warp pins rotate, multi-select and Select All; Control bar and hint
- Alt-dragging near (not on) a selected pin turns the art around it; the pin
  keeps that turn through later warps. arap::Pin gains an angle constraint and
  object.puppetWarp takes angles: [deg|null...].
- Shift-click adds pins to the selection or takes them out; dragging a pin
  moves every selected pin. Select All Pins selects them all (it used to
  deselect). Alt-click no longer deletes a pin; Delete removes the selection.
- Control bar: Expand (0 allowed), Show Mesh (on by default), Select All
  Pins. Hint-bar text for the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:28:05 +07:00
BFlat 5a93e78ad8 Merge pull request #184 from storytold/m8-envelope-text-export
M8.16: Enveloped type exports distorted, run by run, everywhere
2026-10-06 10:27:18 +07:00
bflatasticandClaude Opus 5.5 aafe381eab ROADMAP: M8.16 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:27:09 +07:00
bflatasticandClaude Opus 5.5 42be928367 M8.16: Enveloped type exports distorted, run by run, everywhere
Every exporter (SVG, PDF, EPS, EMF/WMF, DXF) evaluated live objects with no
text outliner, so type inside an envelope was written undistorted. They now
share vectorcraft_effects::expand_live_deep, which outlines type per run in
each run's paint, as the canvas now does too (it used the first run's paint
for the whole line).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:27:08 +07:00
BFlat 3bb1431422 Merge pull request #183 from storytold/m8-envelope-dialogs
M8.15: Envelope dialogs, Reset with Warp/Mesh, full Envelope Options
2026-10-06 10:26:59 +07:00
bflatasticandClaude Opus 5.5 4b5d776a73 ROADMAP: M8.15 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:26:47 +07:00
bflatasticandClaude Opus 5.5 fe6d6640fb M8.15: Envelope dialogs, Reset with Warp/Mesh, full Envelope Options
Make with Warp and Make with Mesh open dedicated dialogs (style dropdown,
orientation, bend and distortion sliders; rows and columns) with live
preview; while an envelope is selected the menu shows Reset with Warp /
Reset with Mesh instead, which switch the envelope's kind and keep its
content (Maintain Envelope Shape samples the current surface). Envelope
Options is a full dialog prefilled from the selection (Anti-Alias,
Preserve Shape, Fidelity, Distort Appearance, Distort Linear Gradients,
Distort Pattern Fills); with nothing selected it sets the defaults for new
envelopes. object.envelope.info reads the settings. Release returns the
warp or mesh shape as a gradient mesh and keeps opacity, blend mode and
mask; Expand keeps name, knockout, isolation and mask; Object > Expand
expands envelopes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:11:10 +07:00
BFlat 6994f248b2 Merge pull request #182 from storytold/m8-define-grid
M8.8: Define Grid dialog prefilled from the grid, with viewing angle, units, scale, colours and opacity
2026-10-06 10:08:39 +07:00
bflatasticandClaude Opus 5.5 f29e139199 ROADMAP: M8.8 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:08:07 +07:00
bflatasticandClaude Opus 5.5 ecf245b7c9 M8.8: Define Grid dialog prefilled from the grid, with viewing angle, units, scale, colours and opacity
View > Perspective Grid > Define Grid... opens a dedicated `perspectiveGrid` dialog
(ui.perspectiveGridDialog) prefilled from the current grid instead of a generic
form with fixed defaults that forced kind 2. The grid model gains (append-only)
name, viewing angle (the station point), units, scale, plane colours and opacity;
grids with a viewing angle foreshorten each axis as a true camera. New commands:
perspective.grid.get (read-only) and perspective.grid.define (one undo step,
only changed fields apply).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:40:26 +07:00
BFlat fe7da442ca Merge pull request #180 from storytold/m8-delete-in-tools
M8.1: Delete/Backspace remove the selected width points and Puppet Warp pins
2026-10-06 09:40:10 +07:00
bflatasticandClaude Opus 5.5 37c82d082e ROADMAP: M8.1 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:39:58 +07:00
bflatasticandClaude Opus 5.5 5d21826fa8 M8.1: Delete/Backspace remove the selected width points and Puppet Warp pins
The Width and Puppet Warp tools now claim Delete and Backspace while they
have width points or pins selected, so the shortcut routing (desktop and MCP
press_key) removes those instead of clearing the artwork. With nothing of the
tool's selected the keys still clear the selected objects. Headless Backspace
clears the selection like the desktop app does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:26:55 +07:00
BFlat a05e251622 Merge pull request #179 from storytold/m8-tool-settings
M8.2: tool options last across tool switches and launches
2026-10-06 09:26:38 +07:00
bflatasticandClaude Opus 5.5 28697019b7 ROADMAP: M8.2 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:26:25 +07:00
bflatasticandClaude Opus 5.5 08108f693f M8.2: tool options last across tool switches and launches
Each tool keeps its options (a table in vectorcraft_tools::settings) when another
tool is chosen, across document switches, and in the saved preferences
(Prefs::tool_settings). The 7 Liquify tools share one set of Global Brush
Dimensions; the Symbolism tools share their brush. tool.setOption takes
{values} and a `tool` (another tool's options are stored for later), returns
the options, and works headless (MCP host command).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:23:44 +07:00
japbcoelhoandClaude Opus 5.5 32f73e9cce CLI: a closed stdout ends vectorcraft-cli quietly
`vectorcraft-cli commands | head -1` panicked and exited with status 101:

  thread 'main' panicked at library/std/src/io/stdio.rs:1168:9:
  failed printing to stdout: Broken pipe (os error 32)

println! and print! panic when stdout is closed. Output now goes through
outln! and out!, which end the program with status 0 on a broken pipe (the
reader has what it wanted) and report any other write error on stderr with
status 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:09:22 -03:00
Brandon Thomas 2c02502f43 Merge pull request #176 from theironchef/fix/dxf-hatch-spline-edge
DXF import: a hatch spline edge without fit data keeps its loop
2026-10-05 21:51:30 -04:00
Brandon Thomas e35f75f733 Merge pull request #174 from theironchef/fix/regions-one-pass
Pathfinder: find all regions in a few contour passes, not one per mask
2026-10-05 21:51:26 -04:00
Brandon Thomas 3f0bce6a88 Merge pull request #138 from theironchef/fix/clippy-1.99
Lint: as_chunks for constant chunk sizes, as clippy 1.99 asks
2026-10-05 21:51:22 -04:00
theironchefandClaude Opus 5.5 a4d93b1462 Lint: as_chunks in the font scan too
The system font scan reads collection offsets and table records with
chunks_exact; clippy 1.99 asks for as_chunks there as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 12:17:22 -07:00
theironchefandClaude Opus 5.5 719e648de1 Lint: as_chunks in two tests too (import_fuzz, render encode)
With the library hits fixed, clippy 1.99 reaches two more in test code.
cargo clippy --workspace --all-targets -- -D warnings now passes on 1.99.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 12:14:56 -07:00
theironchefandClaude Opus 5.5 945dcbae1d Lint: as_chunks for constant chunk sizes, as clippy 1.99 asks
Rust 1.99's clippy adds chunks_exact_to_as_chunks, and cargo clippy
--workspace --all-targets -D warnings (cargo xtask ci) failed on its eight
hits in metafile, pdf and eps. as_chunks gives fixed-size arrays, so the
loops read the same bytes without indexing into slices, and encrypt's
CBC loop takes the remainder from as_chunks instead of the iterator.
as_chunks is stable since 1.88.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 12:14:56 -07:00
theironchefandClaude Opus 5.5 86dbc42d42 DXF import: a hatch spline edge without fit data keeps its loop
A spline edge read an optional fit count (97) after its control
points. Files before DXF 2010 have no fit data there, so the 97 that
follows is the loop's count of source objects: the edge took it, then
failed reading fit points where the 330 handles are, and the whole
hatch was dropped without a warning. That is every associative hatch
bounded by a spline from AutoCAD R2000 to R2007.

A fit count is 0 or followed by fit points (11), and a source count
by handles (330), so the edge now only takes the 97 when it is 0 or
points follow.

Fixes #175.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 12:14:39 -07:00
theironchefandClaude Opus 5.5 9002f86f73 Pathfinder: find all regions in a few contour passes, not one per mask
regions() made one linesweeper contour pass per distinct coverage mask,
and each pass walks the whole arrangement, so finding the regions cost
masks x segments. Two crossing blends make thousands of both: the
Ribbons example's EPS export never finished, and Flatten Transparency,
the Flattener Preview, Divide, Shape Builder and Live Paint slowed down
the same way on dense art.

The masks now go into batches whose faces share no vertex (a greedy
colouring of the masks meeting at each vertex), one contour pass per
batch. A face's contours are walked as in its own mask's pass, since
no other face of the batch touches it; each contour's mask is the one
of its batch at its first point (a vertex of the arrangement), and
contours group under the nearest enclosing contour of the same mask,
as grouped() does in that mask's pass. The regions come out identical,
in the same order.

Fixes #173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 12:10:36 -07:00
BFlat fd99375b1b Merge pull request #166 from theironchef/fix/layers-rename-commit
Layers: renaming a layer or object commits on Enter or a click elsewhere
2026-10-06 01:04:22 +07:00
BFlat f24198ec7e Merge pull request #160 from theironchef/fix/undo-during-interaction
Edit: Undo during typing or a drag takes back only that, and Redo returns it
2026-10-06 01:04:16 +07:00
theironchefandClaude Opus 5.5 8c8ac37300 Layers: an open rename stays with its document
Node ids are per document, so a rename left open while another document
came to the front renamed that document's node with the same id. The
panel drops an open rename when the document it shows changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 10:58:54 -07:00
BFlat 5e901c9c17 Merge pull request #172 from storytold/fix/issue-81
Fix #81: Chinese, Japanese and Korean names show in the UI, not boxes
2026-10-06 00:57:08 +07:00
BFlat b71e843c96 Merge pull request #171 from storytold/fix/issue-36
Fix #36: the font menus list the installed fonts, with a search field
2026-10-06 00:56:54 +07:00
BFlat 2d740acbd1 Merge pull request #170 from storytold/fix/issue-130
Fix #130: installed fonts are found by name whatever ran before
2026-10-06 00:56:40 +07:00
BFlat 242e8a5fd5 Merge pull request #169 from storytold/fix/issue-122-halftone
Fix #122: Object > Vector Halftone
2026-10-06 00:56:27 +07:00
BFlat d0aeeb0076 Merge pull request #168 from storytold/fix/issue-122-color-adjust
Fix #122: colour adjustment live effects (Effect > Color Adjustments)
2026-10-06 00:56:14 +07:00
BFlat 39ac89fc26 Merge pull request #167 from storytold/fix/issue-122-cut-tools
Fix #122: Mirror & Cut, Line Cut and Rectangle Cut tools
2026-10-06 00:55:58 +07:00
theironchefandClaude Opus 5.5 11740c9731 Layers: renaming a layer or object commits on Enter or a click elsewhere
The name field asked for focus every frame, which took it back from
Enter, Escape or a click elsewhere, so lost_focus never fired and the
rename was never committed. It now asks only while opening, as the
Artboards panel's name field does.

Fixes #165

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 10:51:46 -07:00
bflatasticandClaude Opus 5.5 c4b87e36b2 Fix #81: Chinese, Japanese and Korean names show in the UI, not boxes
Layer and object names, document tabs and any other UI text in scripts the
UI fonts lack now draw in an installed font that covers them, as the canvas
already did.

Root cause: egui only had the bundled UI fonts (Source Sans 3, JetBrains
Mono) and its defaults, none with CJK glyphs. After each frame the UI now
looks at the text it painted; the first time a character the UI fonts lack
appears, a thread finds an installed font covering it through the font
database (the canvas's fallback search, so a font the canvas loaded is
reused, its bytes shared rather than copied) and adds it to every egui
family as the last fallback. Nothing is bundled, Latin-only UIs load no
extra font, and on the web, without system fonts, the boxes remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:49:22 +07:00
bflatasticandClaude Opus 5.5 f12428264c Fix #36: the font menus list the installed fonts, with a search field
Type > Font, the Character, Properties and Glyphs panels and Find Font list
every installed font by default, as the reference app does, instead of the
four bundled families. The family dropdowns get a search field that filters
the list as you type (Enter picks the first match) and open on the current
font. Show System Fonts, which no longer means anything, becomes Refresh Font
List (text.rescanFonts): it picks up fonts installed since the app started and
redraws type set in them without editing the document. text.fontList lets MCP
agents list the families and a family's styles.

Root cause: the font lists came from FontDb::families(), which only held the
bundled fonts until something scanned the system fonts (fixed with #130). The
Type > Font menu, rebuilt every frame, now builds its items once per font
change, and renderers lay type out again when the fonts change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:49:22 +07:00
bflatasticandClaude Opus 5.5 49e8413ebe Fix #130: installed fonts are found by name whatever ran before
Opening, placing, pasting or importing a file, the font menus and MCP now find
the installed system fonts (Arial, Times New Roman...) in a fresh session, so a
file renders the same whatever happened earlier, and PDF import keeps the real
family names (Microsoft YaHei, not "Microsoft Ya Hei").

Root cause: FontDb only consulted the system font catalog, which stayed empty
until a CJK fallback or Character panel > Show System Fonts happened to scan
the font folders. Every lookup by family name (face, families, styles,
has_family) now waits for one cataloging scan per session: the app starts it
in the background, other processes run it on first use. The scan reads only
each file's table directory and name table (about 70 ms for 780 faces here,
instead of reading every font file whole), the catalog is indexed by family,
the family list is cached, and an installed style of a loaded family (a
bundled family also installed with more weights) loads when asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:49:06 +07:00
bflatasticandClaude Opus 5.5 285d94a3e6 Fix #122: Object > Vector Halftone
The issue asked for a vector halftone that turns art or an image into
vector dots, lines or shapes by tone, with angle, frequency, shape, mono
or CMYK separations and clipping to the object. Nothing like it existed.

object.vectorHalftone renders the selection offscreen, samples its tone
once per cell of a grid turned by the screen angle and places a shape
whose area follows the tone (circle, ellipse, square, diamond or line).
Mono screens follow lightness in one colour; CMYK screens follow the ink
amounts the CMYK output separates, one screen per ink at the classic
angles, multiplied over each other. Dots are clipped to the art's
outline as real geometry (only the dots crossing it go through a
boolean). It is a command rather than a live effect: the live pipeline
can't sample rendered tone when exporting. Its dialog previews live and
OK keeps it as one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:42:26 +07:00
bflatasticandClaude Opus 5.5 9920156bb6 Fix #122: colour adjustment live effects (Effect > Color Adjustments)
The issue asked for non-destructive colour adjustments in the Effect menu
and the Appearance panel that work on vector paints, live type and
embedded images. Only the destructive Edit Colors commands existed, and
the live-effect pipeline only knew geometry and raster effects.

Effect > Color Adjustments adds six effects (adjust.*). They recolour,
in stack order, everything an object paints with: fills and strokes
(gradient stops too), type, gradient meshes, embedded images and a
group's members, each colour keeping its model. The Edit Colors walk over
an object's colours moved to vectorcraft_doc::recolor so the commands
and the live effects share it. The renderer draws adjusted art through
its object-effect path (recoloured images cached within a memory
budget), exports bake the adjusted colours (images as recoloured
copies), and Expand Appearance makes them permanent. The effect dialog
shows sliders, a channel menu and a draggable Curves graph, all previewed
live as one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:42:26 +07:00
bflatasticandClaude Opus 5.5 a844503e4c Fix #122: Mirror & Cut, Line Cut and Rectangle Cut tools
The issue asked for one-step symmetric shapes, splitting all selected art
along a line and cropping art to a rectangle as real geometry. Only the
freehand Knife and the Scissors existed, and neither works on compound
paths as one shape.

New commands path.mirrorCut, path.lineCut and path.rectCut cut the
selected paths and compound paths (inside groups too) with pathops
booleans against a half-plane or rectangle, and split open paths with
the Knife/Path Eraser helpers. Mirror & Cut reflects the kept half and
unites it with itself (open halves are joined on the axis). The draw2
piece helpers now rebuild compound paths too. Three tools (Eraser group,
original icons) preview the cut live while dragging and commit it as one
undo step; Mirror & Cut's axis and kept side are tool options shown in
the Control bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:42:26 +07:00
BFlat 1c46c4c654 Merge pull request #164 from storytold/fix/issue-31
Fix #31: the Save Changes dialog fits its content and the window at every UI scale
2026-10-06 00:41:25 +07:00
BFlat d396bc23af Merge pull request #163 from storytold/fix/issue-44
Fix #44: right-clicking a grouped tool opens its sub-tools flyout
2026-10-06 00:41:10 +07:00
BFlat 9106e344f8 Merge pull request #162 from storytold/fix/issue-84
Fix #84: the toolbar and the panel icon column scroll in short windows
2026-10-06 00:40:57 +07:00
BFlat 52d6b9a2ba Merge pull request #161 from storytold/fix/issue-82
Fix #82: the window fits the screen and comes back where it was
2026-10-06 00:40:44 +07:00
theironchefandClaude Opus 5.5 2076f82b47 Edit: Undo during typing or a drag takes back only that, and Redo returns it
edit.undo cancelled an open interaction (rolling back the typing session
or drag preview without recording it) and then undid the step before it:
one Undo removed two steps, and the typing could not be redone. Undo now
commits the interaction first, so it is the step taken back and Redo can
restore it; commit_interaction does nothing when nothing is open or
nothing changed.

Fixes #159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 10:39:53 -07:00
bflatasticandClaude Opus 5.5 a52c030963 Fix #31: the Save Changes dialog fits its content and the window at every UI scale
The Save / Don't Save / Cancel prompt (and every dialog in the shared
dialog frame) is as tall as its text and buttons at every UI Scaling value,
including when the scale changes while it is open, and never wider than the
window: at 2x in a small window the question wraps instead of running off
the left edge.

Root cause: the tall, mostly empty prompt in the report was the shared
frame's button row taking all the window's height, fixed on main by
d3df1bce (M14.1). What remained was the width: the frame's minimum and
maximum widths are in points, and at a large UI scale a small window has
fewer points than the dialog's 466 pt, so it overflowed the window. The
frame now caps both widths to the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
bflatasticandClaude Opus 5.5 25a194cc00 Fix #44: right-clicking a grouped tool opens its sub-tools flyout
A right-click on a toolbar button with sub-tools (the small triangle) opens
the flyout and it stays open until a tool in it is chosen or the user clicks
elsewhere. A long press opens it too, and releasing that press no longer
closes it or picks the button's tool.

Root cause: the right-click did open the flyout, but the flyout closes on a
click outside itself, and the release of that same right-click (on the tool
button, outside the flyout) closed it in the same frame. The release of a
long press shorter than egui's click time did the same, and also selected
the button's tool. Clicks on the flyout's own tool button now leave it to
that button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
bflatasticandClaude Opus 5.5 831d916d3d Fix #84: the toolbar and the panel icon column scroll in short windows
In a window too short for them, the toolbar (tools, Fill/Stroke, colour
chips, drawing modes, Screen Mode, Edit Toolbar) and the dock's panel icon
column scroll with the mouse wheel, or with the scroll bar that shows while
the pointer is over them, instead of being cut off at the hint bar.

Root cause: both were drawn straight into their side panels with no scroll
area, and the toolbar's only fallback (two columns) runs out below about
760 pt. They now draw inside widgets::strip_scroll, a vertical ScrollArea
next to the menus' widgets::menu_scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
bflatasticandClaude Opus 5.5 3d890203dd Fix #82: the window fits the screen and comes back where it was
The first window opens at 1440 x 900 when the screen has room for it around a
taskbar or Dock, and maximized (with a size that fits once un-maximized)
when it doesn't, as on 1600 x 900, 1366 x 768 or 150% scaled 1080p screens.
The window's size, position and maximized state are saved with the UI
preferences (UiState::window) and restored at the next launch, moved fully
onto a monitor that is still connected and maximized if it no longer fits.

Root cause: main.rs created the window at a fixed 1440 x 900 at the system's
default position, never checked it against the monitor and never remembered
it. The new apps/vectorcraft/src/window.rs places the still-hidden window
from the monitors winit reports (CreationContext::winit_window) and tracks
its geometry every frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
BFlat 679191b4ca Merge pull request #158 from storytold/fix/issue-121
Part of #121: sandboxed WebAssembly plug-ins (object filters and live effects)
2026-10-06 00:30:34 +07:00
bflatasticandClaude Opus 5.5 811ad1efd8 Fix #121: sandboxed WebAssembly plug-ins: object filters and live effects
VectorCraft had an Additional Plug-ins Folder preference but nothing loaded from it. Port
PhotoCraft's wasmi plug-in host (same sandbox, manifest and parameter schema) as the new
vectorcraft-plugins crate (L2), extended for vector documents:

- Object filters read the selected paths and compound paths as JSON (native PathData and
  Paint forms: geometry, fills, strokes, gradients, opacity) and return the objects to keep,
  change, add or delete; the output is validated in full and applied as one undo step
  (plugin.run). A failing plug-in leaves the document untouched.
- Live-effect plug-ins are geometry effects in the appearance stack (effect id
  plugin.<id>), re-evaluated (and cached) when the object or parameters change, editable,
  exported and baked by Expand Appearance; a missing plug-in leaves the geometry as is.
- plugin.list / info / run / install / remove / reload; the preference folder installs
  every *.wasm at startup and when it changes; File > Open of a .wasm installs it (web).
- Object > Plug-ins and Effect > Plug-ins menus with dialogs generated from the parameter
  schema (live preview), the Appearance panel's fx menu, docs/plugins.md, an original
  example plug-in (Desaturate, a Rust crate outside the workspace) and fuzzing of the
  plug-in boundary (engine/tests/plugin_fuzz.rs).

Tool plug-ins and paint-changing live effects are left for later (docs/plugins.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:19:41 +07:00
BFlat cccdf96cc1 Merge pull request #157 from storytold/fix/issue-37
Fix #37: a name typed without an extension gets the chosen format's on Export too
2026-10-06 00:18:54 +07:00
BFlat 7228275f1c Merge pull request #156 from storytold/fix/issue-40
Fix #40: W/H constrain-proportions link in the Properties panel and Control bar
2026-10-06 00:18:34 +07:00
BFlat 649be860e2 Merge pull request #155 from storytold/fix/issue-33
Fix #33: position and size fields evaluate math typed before the unit suffix
2026-10-06 00:18:15 +07:00
BFlat 4f01bc13b2 Merge pull request #154 from storytold/fix/issue-85
Fix #85: empty point type placed by a click is discarded when editing ends
2026-10-06 00:17:55 +07:00
bflatasticandClaude Opus 5.5 9c707b37fc Fix #37: a name typed without an extension gets the chosen format's on Export too
Save, Save As and Save a Copy already added the save format's extension to a
name typed in the save panel, but every other write through it (Export,
Export Selection, Save as PDF, text and preset exports, Document Info) wrote
the name as typed, so "art" became an extensionless file.

The save panel's answer now goes through one rule (io::with_extension): kept
when it ends in the suggested name's extension or names a known format, else
that extension is added. The web already downloads under the suggested name
or adds the format's extension in its Save As dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:34 +07:00
bflatasticandClaude Opus 5.5 6992c843b4 Fix #40: W/H constrain-proportions link in the Properties panel and Control bar
The Transform panel had the link, but kept it in panel-local egui memory;
the Properties panel's Transform section and the Control bar had none, so
size changes there were never proportional.

The link is now the constrainProportions preference (prefs.get/prefs.set),
one toggle shared by the three places (transform::constrain_link), and every
W/H field passes it to object.setBounds as `proportional`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:34 +07:00
bflatasticandClaude Opus 5.5 6780ffae60 Fix #33: position and size fields evaluate math typed before the unit suffix
A `*` or `/` operand had to be a bare number, so `1080/2 px` (the number
replaced by an expression, the field's suffix still there) read as nothing.
The unit after a factor or divisor now measures the whole expression, in
every unit (`Unit::parse`, shared by every unit field). Numeric fields
(num_field, opt_field, plain/mixed fields) select their whole text, unit
included, when they gain focus or are double-clicked, so typing replaces it;
unitless fields gain the same arithmetic (`parse_number`, `45*2°`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:10 +07:00
bflatasticandClaude Opus 5.5 1499834de7 Fix #85: empty point type placed by a click is discarded when editing ends
Clicking with the Type tool runs text.create at once, and ending the edit
(Escape, another tool, a click elsewhere) only stopped editing, so every stray
click left an invisible empty text object and a "Type" history step.

The Type tool now remembers the point type its click placed and, when editing
ends with it still empty, runs the new text.discardEmpty command: it removes
the text and, when nothing else changed since the step that created it, drops
those steps so neither the document nor the history keeps a trace. A command
from outside the tool that takes the edited text out of the selection
(Deselect, a Layers panel pick) or removes it now ends the edit too
(Tool::after_command). Dragged area-type frames and type in or on a path keep
their frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:09 +07:00
BFlat 8adc629055 Merge pull request #153 from storytold/fix/issue-39
Fix #39: rotated objects keep their angle and a rotated bounding box
2026-10-06 00:03:20 +07:00
BFlat 871474f1a9 Merge pull request #152 from storytold/fix/issue-34
Fix #34: Ctrl/Cmd+Shift+V pastes in place
2026-10-06 00:03:04 +07:00
BFlat b4c39b8cb9 Merge pull request #151 from storytold/fix/issue-86
Fix #86: modifiers sent with ui.key, ui.click and ui.drag reach every handler
2026-10-06 00:02:50 +07:00
BFlat 8939caa284 Merge pull request #150 from storytold/fix/issue-80
Fix #80: the hint bar names keys as the menus do (Alt and Ctrl off macOS)
2026-10-06 00:02:32 +07:00
BFlat dd69402f8b Merge pull request #149 from storytold/fix/issue-83
Fix #83: Ctrl and + zooms in however + is typed (numpad, Shift+=, a + key)
2026-10-06 00:02:03 +07:00
bflatasticandClaude Opus 5.5 96604acffb Fix #39: rotated objects keep their angle and a rotated bounding box
Rotating an object baked the rotation into its geometry and kept nothing
else, so the bounding box, the handles and the Transform/Properties
panels fell back to the page-aligned box (the Rotate field went back to 0).

Root cause: nodes carried no record of their own axes. Each node now
keeps `bbox_angle` (append-only, written only when non-zero), which every
transform turns with the object (mirroring transforms keep the nearer of
the two possible boxes). The geometry is still baked. The selection's box
(`Document::oriented_bounds`) stands at the angle the selected objects
share. The Selection tool hit-tests and scales its handles in that frame,
and the canvas draws it rotated. `object.setBounds` measures W/H and the
reference point along it. `object.rotate {absolute}` turns the selection
to an angle, which the panels' Rotate field now shows and sets. Object >
Transform > Reset Bounding Box (previously a no-op) clears the angle in
one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:36 +07:00
bflatasticandClaude Opus 5.5 d3d6cce917 Fix #34: Ctrl/Cmd+Shift+V pastes in place
Paste in Place is Cmd+Shift+V, but egui reports every Cmd+V chord as a
Paste event (or, for what isn't text, only the key's release), never as
the key, and the handler ran Paste for every Paste event. A paste now runs
the paste command whose V chord is held (Paste in Place, Paste on All
Artboards, Paste without Formatting; user overrides included), Paste for
the Paste key and Shift+Insert. Envelope Distort > Edit Contents also had
Cmd+Shift+V; it loses the duplicate, and a test checks that no two
commands, tools or panels share a default shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
bflatasticandClaude Opus 5.5 72be324732 Fix #86: modifiers sent with ui.key, ui.click and ui.drag reach every handler
The control channel attached its modifiers only to the synthetic key and
button events, while most handlers read egui's held modifiers
(i.modifiers), which only the keyboard set. The raw input hook now sets
egui's modifiers (a ModifiersChanged event) for the frames synthetic input
spans: a key's press, text and release, a click, and a drag from press
through its moves to release; the keyboard's modifiers, tracked from the
host's events, come back after. ui.key's text now comes with the press.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
bflatasticandClaude Opus 5.5 638edec175 Fix #80: the hint bar names keys as the menus do (Alt and Ctrl off macOS)
The hint bar's segments were hard-coded strings with macOS names (Option,
Cmd) next to one Alt. The hints now write keys as shortcut strings
("Alt+Click") and show them through the menus' pretty_shortcut, and the
fallback hint shows the effective Search Commands shortcut (or the menu
item when it has none).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
bflatasticandClaude Opus 5.5 03ea4079f9 Fix #83: Ctrl and + zooms in however + is typed (numpad, Shift+=, a + key)
The shortcut parser mapped "=" to Key::Equals and the matcher only took
that exact key, while egui reports the numpad plus, and + on layouts that
give it its own key (and often Shift+=), as Key::Plus. "+" and "=" now
parse to one chord key, a recorded + is stored as =, and the matcher lets
a = chord take Key::Plus too (also next to the native macOS menu, which
handles only the chord as written).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
BFlat e94dfed775 Merge pull request #148 from storytold/fix/issue-136
Fix #136: SVG export and CSS write each style's numeric font-weight
2026-10-05 23:48:42 +07:00
BFlat 2f75b6cd29 Merge pull request #147 from storytold/fix/issue-139
Fix #139: SVG import paints paint-order="stroke" text strokes under the letters
2026-10-05 23:48:24 +07:00
BFlat 69dc539db5 Merge pull request #146 from storytold/fix/issue-131
Fix #131: SVG import keeps non-scaling strokes at their screen width
2026-10-05 23:47:51 +07:00
bflatasticandClaude Opus 5.5 30550f038a Fix #136: SVG export and CSS write each style's numeric font-weight
font_props wrote `bold` for any style whose name contained bold, black
or heavy and nothing for the others unless fonts were embedded, so
Semibold came back Bold and Light or Medium came back Regular. It now
writes the style's numeric weight (font_descriptor, from style_weight)
for every writer: 600 for Semibold, 300 for Light, 900 for Black, `bold`
for 700 and nothing for 400. The SVG import already reads these numbers
back to the matching face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:36:29 +07:00
bflatasticandClaude Opus 5.5 535220cf34 Fix #139: SVG import paints paint-order="stroke" text strokes under the letters
The text import reads <text> from the XML and never looked at
paint-order, so character strokes always painted over the fill. A text
whose characters all share one stroke painted first now gets that
stroke as the object's own stroke below the Characters row (as the
canvas, SVG and PDF export already draw it), with its gradient or
pattern resolved in the document; characters with differing strokes
keep them and the import warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:36:29 +07:00
bflatasticandClaude Opus 5.5 65dd757d59 Fix #131: SVG import keeps non-scaling strokes at their screen width
usvg knows vector-effect but drops it from the tree it returns, so every
stroke was scaled by its transforms. The importer now records the shapes
that ask for vector-effect="non-scaling-stroke" in its XML prepass (by id)
and gives their strokes and dashes the width they have on screen; type
does the same on its character strokes. Where the width can't be kept
(art in patterns, markers or <use> copies, stretched or skewed type) the
import warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:36:29 +07:00
BFlat 088ffc844b Merge pull request #144 from storytold/fix/issue-87-rust-version
Fix #87: rust-version 1.95 and clippy 1.95
2026-10-05 23:05:47 +07:00
bflatasticandClaude Opus 5.5 885b7acac1 Fix #87: rust-version is 1.95, and clippy 1.95 passes
egui 0.36 needs rustc 1.95 (hayro and krilla 1.92), so the declared 1.90 let
cargo fail on dependency versions instead of naming the toolchain. Clippy 1.95
also flagged four collapsible matches that newer clippy no longer does; each is
rewritten without changing behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:05:32 +07:00
BFlat 63bf2c4598 Merge pull request #143 from storytold/pr/scroll-long-menus
M14.1, M14.3, M14.7: scrolling menus, Home button, Cmd+N opens New Document
2026-10-05 22:44:21 +07:00
bflatasticandClaude Opus 5.5 d0d934bfda M14.7: Cmd/Ctrl+N opens the New Document dialog
The engine's file.new also claimed Cmd+N and engine commands win shortcut
lookups, so the shortcut made a blank document. file.newDialog now owns it,
as the docs already said; file.new stays the headless command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:43:48 +07:00
bflatasticandClaude Opus 5.5 258337cf7c M14.3: the Home button shows the Home screen over open documents
It used to open the New Document dialog. Now it runs the new app.home UI command:
the Home screen replaces the canvas while the documents stay open as tabs;
choosing a tab, or a document opening, closing or becoming active, returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:43:48 +07:00
bflatasticandClaude Opus 5.5 d4c4361b4a M14.1: long menus scroll inside the window and use all the height they have
Menu bar menus, their submenus, panel menus, the effects menu groups and library
submenus are as tall as their items down to the window's bottom edge, and scroll
only past that (widgets::menu_scroll), instead of running off screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:43:48 +07:00
Brandon ThomasandClaude Opus 5.5 0aba6ef6e4 Merge main (#129, the shared FreeBSD CI template) into pr/freebsd-release
The build job follows the shared template; this branch adds only the package job
and its PR paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:20:41 -04:00
Brandon Thomas 36a6a8ae49 Merge pull request #129 from storytold/pr/freebsd-ci-sync
FreeBSD CI: match the shared template
2026-10-05 10:04:50 -04:00
Brandon ThomasandClaude Opus 5.5 2b15d3234f FreeBSD CI: match the shared template (git + safe.directory, 4 vCPUs, pinned vmactions/freebsd-vm)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:45:18 -04:00
Brandon ThomasandClaude Opus 5.5 980372f6d0 FreeBSD: release package and a packaging check in CI
- packaging/freebsd/package.sh builds vectorcraft and vectorcraft-cli and writes
  vectorcraft-<version>-freebsd-x86_64.tar.gz, a /usr/local-style tree (bin/, the
  freedesktop desktop entry, MIME type and metainfo, hicolor icons, licences, NOTICE
  and ASSETS.md). FreeBSD's install(1) has no -D and uname -m says amd64.
- release.yml: a FreeBSD x86_64 job in the same FreeBSD 14.3 VM; the version, date
  and SHA reach the VM through `envs:`. The draft release waits for it.
- freebsd.yml: vmactions/freebsd-vm pinned by commit (v1.5.9), git safe.directory for
  the copied checkout, CARGO_BUILD_JOBS=4, and a `package` job that runs the script on
  PRs touching FreeBSD packaging and on demand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:25:46 -04:00
Brandon Thomas a867c1e020 Merge pull request #126 from storytold/pr/freebsd
FreeBSD: build and test the workspace in a FreeBSD VM
2026-10-05 08:50:50 -04:00
Brandon ThomasandClaude Opus 5.5 7c0a253b50 docs: point agents to the shared test corpora (#125)
Real-file test oracles live in storytold/photocraft-corpus, explained in
craftrules standards/test-corpora.md. Never commit large binary fixtures
here; fetch them pinned and sha256-verified.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:24:28 -04:00
BFlat 7a861ae064 Merge pull request #127 from storytold/pr/release-0.3.0
Release: VectorCraft v0.3.0
2026-10-05 19:23:31 +07:00
Brandon ThomasandClaude Opus 5.5 466f863d00 FreeBSD: build and test the workspace in a FreeBSD VM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:23:19 -04:00
bflatasticandClaude Opus 5.5 a1deca3d13 Release: VectorCraft v0.3.0
Bump the workspace version 0.2.0 -> 0.3.0 (cargo xtask version set 0.3.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:23:19 +07:00
BFlat cd8973a520 Merge pull request #124 from storytold/pr/m4-pdf-thumbs-linearize-13
M4.86–M4.87: PDF thumbnails, fast web view and PDF 1.3 (M4 done)
2026-10-05 18:06:10 +07:00
bflatasticandClaude Opus 5.5 79581c7ff5 M4.87: PDF 1.3 compatibility: transparency flattened with the chosen preset
compatibility "1.3" writes a %PDF-1.3 file (its XMP metadata says 1.3
too) without transparency: a copy of the document is flattened first
with flattenerPreset (stored with PDF presets; empty is High Resolution)
and the flattener options over it, see-through image pixels counting as
transparency and rasterized areas clipped to their regions so no soft
mask is needed. Page groups are left out and the writer refuses a file
that would still have transparency. PDF/X-1a and PDF/X-3 share this
path (their version is now PDF 1.3; 1.4 is still accepted and written
as 1.3), and with it the preset, the header and metadata code and the
transparency check. Passwords at PDF 1.3 encrypt with 40-bit RC4
(revision 2). The Save PDF dialog lists PDF 1.3 and picks the flattener
preset under Advanced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:05:36 +07:00
bflatasticandClaude Opus 5.5 8961824876 M4.86: PDF page thumbnails and fast web view (linearised files)
Embed Page Thumbnails gives every page a /Thumb image: the page drawn on
white, 106 px on its long side, without the layers the page leaves out
(the engine draws them; vectorcraft_pdf::page_areas says what each page
shows; PDF/X files get grey ones). Optimize for Fast Web View writes a
linearised file: the objects are put in linearised order and numbering,
encrypted when a password asks (the encryption dictionary goes with the
catalog, PDF layers' groups too), then laid out with the linearization
dictionary, both cross-reference tables and the page offset and shared
object hint tables (the hint stream encrypted like any other). Their
"not applied yet" warnings are gone. Reading the objects back uses the
lexer of encrypt.rs (syntax.rs adds what rewriting needs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:05:36 +07:00
BFlat 87df4a0648 Merge pull request #123 from storytold/pr/m4-pdf-layers-overprint
M4.82–M4.83: PDF layers and overprint
2026-10-05 17:40:04 +07:00
bflatasticandClaude Opus 5.5 a09cbe58c8 M4.83: overprinting fills and strokes overprint in PDF
Advanced > Overprint: Preserve (the default; document.exportPdf
advanced.overprint) writes each overprinting fill and stroke (an object's
own, a character's, a type object's) with a graphics state of /OP true
/op true /OPM 1 (/OPM 0 in PDF/A); Discard writes none. White ones knock out
when Document Setup's Discard White Overprint is on, as in print. Mask art
never overprints. File > Print's composite PDF honours Print > Advanced >
Overprints: Preserve the same way (separations keep multiplying plates).

Each overprinting fill or stroke is drawn as a form marked to overprint
(crates/pdf/src/forms.rs); after writing, its content starts by setting the
overprinting graphics state, and the PDF/X self-check runs on the result.
The engine's "overprint is not written to PDF yet" warning and print's
"overprints print as knockouts in composite output" warning are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:39:28 +07:00
bflatasticandClaude Opus 5.5 0e4d3f6871 M4.82: PDF export writes top-level layers as PDF layers
Create PDF layers from top-level layers (document.exportPdf createLayers, the
Save PDF dialog's General option) now writes an optional content group per
top-level layer (template layers are left out): named as the layer, off when
the layer is hidden, /PrintState /OFF when its Print option is off, locked
when it is locked, listed top first, with a print auto-state (not under a
standard). The file reopens with those layers.

The PDF writer has no optional content, so each layer is drawn as a form of
its own (no transparency group, so it draws as in place) starting with a mark;
after writing, the uses of the layers' forms are wrapped in /OC ... BDC/EMC
and the catalog gets /OCProperties (crates/pdf/src/forms.rs, through the
shared patch writer and the PDF syntax reader of encrypt.rs). PDF 1.4 and a
knockout page group write plain page content with a warning; the dialog greys
the option at 1.4. The "not written yet" warning is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:39:28 +07:00
BFlat d7e9f2a47a Merge pull request #120 from storytold/pr/m4-print-tiling
M4.80–M4.81: Print Tiling and advanced print options
2026-10-05 17:27:45 +07:00
bflatasticandClaude Opus 5.5 1f922fcbbc M4.81: Print advanced options: overprints, flattener, printer profile, bitmap
The pdf crate's PrintSettings gains advanced {printAsBitmap,
overprints: preserve|discard|simulate, flattenerPreset} and
color.profile, all defaulting so saved print_setup values still load.
Composite output discards overprints (no /OP) or simulates them as
Overprint Preview draws them; separations keep them. A printer profile
converts composite colours with the chosen intent (a CMYK one writes
device CMYK) and separations separate with a CMYK one. The engine
flattens transparency with the preset, or renders each printed region
as one image for Print as Bitmap, before PDF or PostScript layout.
The Print dialog's Advanced and Color Management sections show them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:27:00 +07:00
bflatasticandClaude Opus 5.5 1b9b37454b M4.80: Print Tiling tool and View > Show Print Tiling
View > Show Print Tiling (view.printTiling) draws the pages of the
document's print settings on the canvas, dashed and numbered, from
print.preview's own layout (print.tiling), so the canvas and the
printed job agree. The Print Tiling tool replaces the no-op tool: a
drag places the first page's imageable corner (print.tiling.set,
saved as print_setup.tileOrigin, one undo step); a double click on
the canvas or the tool button resets it. Tiles start at the origin
and extend to cover the art; the Print dialog shows the tool's
placement with a Reset button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:26:59 +07:00
BFlat 9779d21962 Merge pull request #119 from storytold/pr/m4-pdfx
M4.84: PDF/X-1a, PDF/X-3 and PDF/X-4
2026-10-05 17:19:55 +07:00
bflatasticandClaude Opus 5.5 a825aed763 M4.84: PDF/X-1a, PDF/X-3 and PDF/X-4 files written as their standards say
document.exportPdf {standard: pdfX1a|pdfX3|pdfX4} and the PDF/X presets now
write conforming files instead of being refused:

- every PDF/X file gets a /GTS_PDFX output intent (by default the CMYK
  profile in effect, embedded), TrimBox, Trapped, GTS_PDFXVersion (and
  GTS_PDFXConformance for X-1a), a title and dates; editing data,
  passwords and, in X-1a/X-3, PDF layers are refused
- PDF/X-1a converts every colour and image to a CMYK destination,
  untagged, with CMYK spot alternates; X-3 and X-4 tag colours with ICC
  profiles
- X-1a and X-3 are written as PDF 1.3 and their transparency is flattened
  first (High Resolution preset; images with see-through pixels count,
  rasters stay opaque); X-4 keeps transparency, is PDF 1.6 at most and
  names the standard in its XMP metadata
- the written file is checked against its standard (soft masks,
  transparency groups, opacity, blend modes, RGB in X-1a, fonts not
  embedded) and refused rather than mislabelled
- choosing a standard sets the latest compatibility it allows and turns
  off what it forbids (engine and Save PDF dialog)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:19:11 +07:00
Brandon Thomas 4e72f8c0ec Merge pull request #118 from storytold/docs/honest-assessment
Roadmap: honest assessment, the gap list and where we're going
2026-10-05 06:11:31 -04:00
Brandon ThomasandClaude Opus 5.5 2b48392c36 Roadmap: honest assessment, the gap list and where we're going
- ROADMAP: a new "Honest assessment (2026-10-05)" section with:
  - scores by dimension (breadth, depth, large missing subsystems, interaction
    fidelity, look and feel, file interop, bundled content, performance,
    robustness, platforms, automation) and how far to trust the self-graded scores;
  - the prioritized gap list with one-agent hour sizes;
  - the direction (next, then, alongside, 1.0).
- ROADMAP: the parity rows for files, export, print, links and UI chrome are
  re-derived after M4 (feature parity ~74% on the rubric, 68–74% honest range;
  ~230–350 h to feature parity, ~335–520 h to full parity, one agent), the M4 and
  M11 milestone rows and the inventories are updated, and "Shipped so far" lists M4
  and the robustness work.
- README Status: where we are, what's missing, where we're going.
- AGENTS.md / CLAUDE.md: sessions start from the gap list; landing a task updates
  the assessment, the parity row and the README status in the same PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:11:03 -04:00
BFlat 491b79dd9c Merge pull request #117 from storytold/pr/m4-eps-import
M4.91–M4.92: EPS and PostScript import, print to PostScript
2026-10-05 17:07:02 +07:00
bflatasticandClaude Opus 5.5 0428e56342 M4.92: Print to a PostScript file
file.print {format: "postscript"} (the default for a .ps path) writes the
print job as Level 3 or 2 (level) PostScript instead of a PDF: DSC comments
with %%Pages the page count, one page per sheet with its paper size,
printer's marks in Registration, separations naming their ink (%%PlateColor)
with its halftone screen, a fixed flatness and film negatives. Transparency
is flattened first with flattenerPreset. The answer says which format was
written. In the app, a PostScript job is always saved, never sent to the
printer (the Print dialog's own options are unchanged).

The PDF print's layout is exposed as vectorcraft_pdf::plan (the PDF writer
uses it too); vectorcraft_eps::print draws its sheets with the EPS writer's
document walk, each sheet once however many copies print.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:01:36 +07:00
bflatasticandClaude Opus 5.5 6752b22783 M4.91: EPS and PostScript files open and place
document.open and file.place read .eps files and PostScript by any name
(PostScript .ai opens as artwork, .ait as a new untitled document). An EPS
file VectorCraft wrote restores the document it carries (restored: true);
other files run through a small clean-room PostScript interpreter (paths,
colours and spot inks, gsave/grestore, concat, line state, bind def,
procedures, loops and dictionaries, clips, shadings, images through the
decoding filters, type as point type), and fall back to their TIFF preview
with a warning when the program can't be read. Operation, nesting, memory
and path budgets keep hostile files from hanging or exhausting memory.

The clipping-group nesting the metafile reader used moves to
vectorcraft_doc::clipnest, shared by both readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:51:54 +07:00
BFlat 9f8b7334f2 Merge pull request #116 from storytold/pr/m4-print-dialog
M4.78–M4.79: Print dialog, system printing and print presets
2026-10-05 16:36:16 +07:00
bflatasticandClaude Opus 5.5 7c748a5e3c M4.79: print presets in the Print dialog and Edit > Print Presets
The Print dialog's Print Preset list loads [Default] or a saved preset
(reading [Custom] once the settings differ) and Save Preset... names the
settings as a preset. Edit > Print Presets... lists [Default] (generated,
protected) and the saved presets with how they differ from it; New... and
Edit... open the preset editor (the Print dialog's sections), Delete,
Import... and Export... use .vcprintpresets files, which File > Open also
imports. Commands print.presets.list/save/delete/import/export; the presets
live in Prefs.print_presets and persist with the preferences.

The open-dialog filter count test counts the new Print presets filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:30:46 +07:00
bflatasticandClaude Opus 5.5 ffce1cf686 M4.78: File > Print opens the Print dialog and prints through the system
The Print dialog (Ctrl/Cmd+P) edits the document's print settings in the
sections General, Marks and Bleed, Output, Graphics, Color Management,
Advanced and Summary, with a preview of each printed page under the section
list (drag the art to move its placement). It drives print.setup,
print.preview and file.print; the preview pages now carry their transform,
printed area and trim box. Print sends the job's PDF to the chosen printer
through the new PrintService (lp on macOS/Linux, the shell print verb on
Windows, a hidden frame and the browser's print dialog on the web); without
one, or with PDF File, it saves the PDF. Done only keeps the settings.
UI commands file.print, print.printers and print.printerSetup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:30:35 +07:00
BFlat f1f7b1f945 Merge pull request #115 from storytold/pr/m4-pdf-color-text
M4.73–M4.74: PDF colour conversion, profiles and real text
2026-10-05 16:29:54 +07:00
bflatasticandClaude Opus 5.5 a5367b0846 M4.74: real text with embedded subset fonts in PDF
With Convert Text to Outlines off (advanced.outlineText: false), type is
written as selectable, searchable text: each glyph in its font, embedded
as a subset with a ToUnicode map, where its outline would be (rotated,
scaled, skewed and on-path type too; gradients keep their place). Fonts
whose licence forbids embedding, or allows only whole fonts, fall back to
outlines with a warning; a subset threshold below 100% is accepted with a
warning. Laid-out glyphs now carry their glyph id and transform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:25:21 +07:00
bflatasticandClaude Opus 5.5 5b69862a08 M4.73: PDF colour conversion, ICC profiles and output intent
Save PDF > Output now applies: colours convert to a destination profile
(destination, or preserveNumbers keeping the numbers of colours already in
its model; grey stays grey; images and freeform gradient images convert
too; printer's marks don't). Profile inclusion writes ICC-based colour
spaces (CMYK with the destination's or the document's profile, RGB as sRGB,
grey), and transparency in CMYK output blends in the CMYK profile's space.
The output intent profile is embedded as the catalog's /GTS_PDFX output
intent with its condition, identifier and registry, and Trapped goes in
the document information (PDF/A keeps its own). A shared patch module
edits written PDFs (Lab spot alternates use it too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:25:20 +07:00
BFlat 51f6817a0b Merge pull request #114 from storytold/pr/m4-psd-export
M4.70: PSD export
2026-10-05 16:22:36 +07:00
BFlat ced1527903 Merge pull request #113 from storytold/pr/m4-css-properties
M4.96: CSS Properties
2026-10-05 16:13:53 +07:00
bflatasticandClaude Opus 5.5 c06c13f577 M4.70: PSD export, flat or with layers, groups for sublayers and text layers named after their text
document.export {format: "psd"} writes layered bitmaps (8 bits per channel),
written from the format's public specification: RGB, CMYK (ink amounts) or
grayscale, the resolution and an embedded profile, PackBits channels, and the
merged image (the flat render). layers (default true) draws each top-level
layer alone as a pixel layer, cropped to what it paints, with its name,
opacity and blend mode; a background colour becomes a Background layer.
maxEditability turns layers and sublayers into groups and every object into a
layer named as the Layers panel names it (text by its text); clipping, masked,
knockout or appearance-carrying layers stay one layer. hiddenLayers writes
hidden layers hidden instead of leaving them out; layers: false writes a flat
image on white. At most 30000 pixels a side.

Export As offers PSD with the psdOptions dialog (the PNG Options rows, then
Color Model, Flat Image / Write Layers with Maximum Editability and Include
Hidden Layers, Embed ICC Profile). Tests read the files back with the psd
crate (MIT OR Apache-2.0, a dev-dependency).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:12:03 +07:00
bflatasticandClaude Opus 5.5 ed4eceb837 M4.96: Window › CSS Properties shows, copies and exports the CSS of the selection
The svg crate's new CSS writer (css_rules) gives the CSS web pages style objects
with: rectangles and ellipses as background-color / linear-/radial-gradient(),
border and border-radius; type as font, color, spacing and alignment; shadows,
glows and blur as box-shadow, text-shadow and filter. It shares SVG export's
declarations (transparency, fonts, type spacing, colours, gradient stops), which
moved into svg/src/css.rs. Options: units, absolute position, width and height,
unnamed objects, rasterize unsupported art (as a PNG background-image).

Commands css.selection, css.generate and css.export; UI commands css.copy and
css.exportFile; the CSS Properties panel with a highlighted code view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:03:11 +07:00
BFlat 0f161926cf Merge pull request #112 from storytold/pr/m4-asset-export
M4.65–M4.66: Asset Export and Export Selection
2026-10-05 15:32:16 +07:00
BFlat 39a7af7eba Merge pull request #111 from storytold/pr/m4-save-for-web
M4.90: Save for Web (Legacy)
2026-10-05 15:32:03 +07:00
BFlat df4b84048e Merge pull request #110 from storytold/pr/m4-tiff-bmp-tga
M4.67–M4.69: TIFF, BMP and Targa export
2026-10-05 15:31:49 +07:00
BFlat db7164a228 Merge pull request #109 from storytold/pr/m4-emf-wmf
M4.94–M4.95: EMF and WMF
2026-10-05 15:31:36 +07:00
BFlat 6017846ef6 Merge pull request #108 from storytold/pr/m4-dxf-import
M4.93: DXF import
2026-10-05 15:31:22 +07:00
BFlat c816f16bad Merge pull request #107 from storytold/pr/m4-data-recovery
M4.37–M4.38: Data Recovery and native save options
2026-10-05 15:31:08 +07:00
BFlat 88b9eee4ad Merge pull request #106 from storytold/pr/m4-pdf-security
M4.85: PDF security
2026-10-05 15:30:53 +07:00
BFlat 2bab433c92 Merge pull request #105 from storytold/pr/m4-print-engine
M4.75: print engine
2026-10-05 15:30:39 +07:00
BFlat 1e09016b80 Merge pull request #104 from storytold/pr/m4-ft3
M14.4: flaky paste fuzz test and batch journaling
2026-10-05 15:30:26 +07:00
bflatasticandClaude Opus 5.5 46dc67ca50 M4.66: File > Export Selection collects the selection and opens the Assets tab
Export Selection… (file.exportSelection, a UI command) now collects the
selected objects as assets, one per object (multiple: false makes one),
reusing assets of art already collected, and opens Export for Screens on
its Assets tab with just those checked. document.exportSelection stays the
one-shot export for agents and scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:56 +07:00
bflatasticandClaude Opus 5.5 6757c7f9f3 M4.65: Asset Export panel and the Assets tab of Export for Screens
Window > Asset Export lists the document's assets as thumbnails: art
dragged in off the canvas (or added with +) becomes one asset per object,
or one of them all with Alt; Object > Collect for Export does the same.
Assets name their objects, so they follow edits (moving the art moves the
crop) and deleting the art removes it from its asset. Click selects,
double-click renames, the bin removes. The panel's export settings are
Export for Screens' own format rows on the document's shared settings, and
Export writes each asset alone, cropped to its art, into a picked folder
(the web downloads a zip). Export for Screens' Assets tab lists the assets
with checkboxes and exports the checked ones.

Commands: assets.add, assets.remove, assets.rename, assets.list,
assets.export, assets.settings.set; document.exportForScreens takes
assets. Model: Document.assets (ExportAsset {id, name, nodes}).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:54 +07:00
bflatasticandClaude Opus 5.5 c6450f6f70 M4.90: Save for Web (Legacy) previews and saves optimised GIF, JPEG, PNG-8 and PNG-24 images, slices and HTML
File > Export > Save for Web (Legacy) (Ctrl/Cmd+Alt+Shift+S) opens a dialog with Original,
Optimized, 2-Up and 4-Up views showing each file's size and download time, and settings for
colour reduction, colours, dither, transparency and matte, interlacing, web snap, lossy GIF,
JPEG quality, the colour table (lock, map to transparent, web shift, sort), image size and
anti-aliasing, Clip to Artboard, sRGB, metadata, slices (all or selected), HTML output and
presets. Preview in Browser opens the HTML page.

Engine: document.exportForWeb, document.exportForWeb.preview (its byte count is the size of
the file Save writes), webExport.settings and webExport.presets.list/save/delete. Saved presets
and the remembered settings are stored in Prefs. Render: quantize_locked, and encode::web for
web snap, colour table edits, lossy GIF and GIF/JPEG comments. PNG files get an sRGB chunk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:51 +07:00
bflatasticandClaude Opus 5.5 b578c32f4d M4.69: Targa export at 16, 24 or 32 bits per pixel
document.export {format: "tga"} writes uncompressed true-colour Targa files
through the raster export path (resolution, background, anti-aliasing,
artboard ranges): depth 16 (5-5-5 with a one-bit alpha), 24 (flattened on
white) or 32 (8-bit alpha), rows bottom-up, with the version 2 footer.
Export As offers it with the tgaOptions dialog (Depth).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:51 +07:00
bflatasticandClaude Opus 5.5 f2fc8782c7 M4.68: BMP export at 1 to 32 bits, Windows or OS/2, with RLE and flipped rows
document.export {format: "bmp"} writes BMP through the raster export path:
depth 1 (black and white), 4 or 8 (a palette by reduction and dither,
optionally RLE4/RLE8), 16 (5-5-5), 24 or 32 (with alpha, a version 4
header); colorModel rgb or gray; fileFormat windows or os2 (a core header);
flipRows writes top-down rows (a negative height). Impossible combinations
are refused as bad params. Export As offers it with the bmpOptions dialog,
which keeps its choices writable together; BMP moves from read-only to read
and write (tests that used .bmp as the unwritable format now use .dwg).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:35 +07:00
bflatasticandClaude Opus 5.5 d01b2fbd8a M4.67: TIFF export in RGB, CMYK or grayscale, with LZW, either byte order and the profile
document.export {format: "tiff"} writes baseline TIFF through the raster
export path (resolution, background, anti-aliasing, artboard ranges):
colorModel rgb (transparency as unassociated alpha), cmyk (ink amounts,
CMYK colours keep their inks) or gray, lzw (weezl), byteOrder little (II)
or big (MM), embedIcc, and the resolution tags. Export As offers it with
the tiffOptions dialog; TIFF moves from read-only to read and write.

The TIFF writer is shared: EPS previews now use it (and its PackBits)
instead of their own copy, so eps depends on render (layers allow it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:29 +07:00
bflatasticandClaude Opus 5.5 961ca75a28 M4.95: EMF and WMF open, place and paste as vector objects
crates/metafile import: EMF and WMF (placeable or not) records play on a
small device-context model (objects numbered as GDI does, saved DCs, world
transforms and mapping modes, path brackets, the current position) into one
artboard (the picture's frame) and one layer: paths with fills and strokes
(pens with caps, joins, dashes), clipping groups for clips, images from DIBs
(StretchDIBits, BitBlt, StretchBlt, AlphaBlend, StretchDIB, DIBBitBlt) and
point type in the font the file names. Lines drawn one after another join
into one path; a clip around the whole picture is dropped; objects far
outside it are left out; unknown records are skipped with one warning.

document.open and file.place read .emf and .wmf through the shared loader
(Open and Place filters list them). clipboard.importEmf loads a picture into
the clipboard, and Paste takes image/emf from a system clipboard service
ahead of text and bitmaps. The import fuzzer covers mutated and random-record
metafiles opened, placed and pasted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:27 +07:00
bflatasticandClaude Opus 5.5 ab1c6b0516 M4.94: EMF and WMF export from Export As and document.export
New crates/metafile (L3, pure Rust): hand-written EMF and WMF writers over
one shared document walk. EMF keeps Bezier paths, solid brushes, geometric
pens (width, caps, joins, miter limit, user-style dashes), clipping masks as
path clips, images (StretchDIBits, or AlphaBlend with transparency) placed by
a world transform, type as outlines; gradients become images clipped to their
shape and pattern fills their tiles clipped to it. The header's frame is the
artboard in 0.01 mm on a 100 px/mm reference device. WMF flattens curves into
polygons in 16-bit units behind a placeable header (with its checksum),
numbers objects as players do, and reports what it leaves out (clipping,
transparency, gradients, rotated images).

document.export {format: emf|wmf} (and serialize, export) writes one artboard,
or one file per artboard with useArtboards (false: the art's bounds); raster
effects are rendered to images first, as for PDF. Export As lists EMF and WMF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:24 +07:00
bflatasticandClaude Opus 5.5 2e6585d7ea M4.93: DXF drawings open and place with layout, scale or fit, centring and merged layers
ASCII DXF of any version opens (document.open) and places (file.place) through
a new reader in vectorcraft-cad: lines, LW/2D/3D polylines with bulge arcs,
circles, arcs, ellipses, NURBS splines (exact Beziers up to cubic, fitted
otherwise), solids, solid and pattern hatches, TEXT/MTEXT/ATTRIB as point
type, named blocks as symbols (one per look for by-block art), anonymous
blocks and dimensions as groups, MINSERT arrays, OCS extrusion; layers keep
off/frozen/locked/plot state; ACI and true colour, lineweights, linetypes and
transparency resolve through layers and blocks. The `dxf` option object picks
the layout, ratio or fit, lineweight scaling, centring and merged layers;
document.dxfInfo lists layouts, layers and units. Binary DXF and DWG answer a
clear error. The DXF Import Options dialog (dxfImport) asks on open and place
with a layout preview. Fuzzed in import_fuzz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:21 +07:00
bflatasticandClaude Opus 5.5 bf4614669e M4.38: native and .ai save options: each artboard to its own file, linked files, ICC profiles, PDF content
- document.save / Save As / Save a Copy to a native or .ai file take
  separateArtboards + range (also write <name>-<artboard>.<ext> per artboard
  beside the master file, holding that artboard and the art touching it; the
  result lists every file), includeLinked (linked images keep their files' own
  pixels), embedProfiles (ICC profiles loaded from files that the document is
  tagged with; opening installs them where missing) and pdfCompatible (native:
  also carry a PDF of every artboard; .ai: false writes blank pages around the
  native document). .ai files also take compress (compression.compressText).
- File > Save As to a native or .ai file shows the options dialog (VectorCraft
  Options) after the save panel: Save each artboard to a separate file (All or a
  range), Include Linked Files, Embed ICC Profiles, Create PDF-Compatible File,
  Use Compression, Preview; the web's Save As dialog shows them too. Save, Save a
  Copy and Save as Template reuse the options as last saved.
- Format: optional "profiles" and "pdf" members (load_file, pdf_content),
  SaveOptions.include_linked/profiles/pdf; files without them are unchanged.
- Changed test: tests_aisave::save_as_ai_writes_a_pdf_that_reopens_editable now
  confirms the options dialog Save As .ai shows before writing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:19 +07:00
bflatasticandClaude Opus 5.5 d8d1d472cf M4.85: PDF security: encryption at every compatibility level, permissions, either password opens
- pdf/src/encrypt.rs encrypts a written PDF with the standard security handler: 128-bit RC4
  (PDF 1.4, revision 3; PDF 1.5, revision 4), 128-bit AES (1.6) or 256-bit AES (1.7/2.0,
  revision 6), every string and stream, plaintext XMP metadata when asked, the permission bits
  (printing none/low/high, five change levels, copy, screen readers). Crypto from RustCrypto
  (aes, md-5, sha2: MIT OR Apache-2.0, pure Rust, wasm).
- PDF/A and PDF/X refuse passwords; equal passwords and passwords the algorithm can't take are
  refused.
- Opening: the permissions password opens RC4/AES-128 files too; editing data comes back from
  an encrypted PDF opened with its password.
- Save PDF › Security: the passwords, permissions and plaintext metadata, the encryption level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:16 +07:00
bflatasticandClaude Opus 5.5 975067b44a M4.37: Data Recovery keeps copies of modified documents and offers them after a crash
- Every Preferences > File Handling > Data Recovery interval, modified documents
  changed since their last copy are written to the recovery store in the
  background (the same snapshot and encoder as Background Save; the status bar
  shows it). Complex documents (over 20,000 objects) are skipped while Turn off
  Data Recovery for complex documents is on.
- A copy goes when its document is saved, reverted, closed or undone back to its
  saved state, and when the app quits with nothing unsaved.
- Each running app keeps its copies in an area of its own: a sub-folder whose
  .lock file it keeps locked for its lifetime (File::try_lock), or on the web a
  heartbeat refreshed every minute. Only areas nobody holds (lock free, or no
  heartbeat for three intervals, at least three minutes) are offered, and an
  area is held while it is restored or discarded, so a second running instance
  never offers or deletes a live instance's copies. Empty leftover areas are
  tidied away.
- The next launch after a crash asks with the Recover Documents dialog (Restore /
  Discard / Cancel): restored documents open as "<name> [Recovered]", unsaved, and
  Save asks where to save them, suggesting their original file; the copy moves
  into the new app's area.
- Store: the recoveryFolder preference, else Data Recovery beside the desktop
  app's preferences; browser storage on the web (Services::recovery_store);
  tests use an in-memory store (with in-memory locks, or heartbeats).
- Engine: file.recovery.save, file.recovery.list, file.recovery.restore {file?},
  file.recovery.discard {file?}; DocState.recovered and DocState.recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:16 +07:00
bflatasticandClaude Opus 5.5 b379f02527 M4.75: File → Print writes the job as a print-ready PDF: paper, scale, tiles, marks and bleed, separations
file.print {settings?, path?} lays the document out on paper and writes one PDF page per sheet:
copies (collated, reversed), artboards (all, a range, ignored), blank artboards skipped, paper
size and orientation (or turned per artboard), transverse, Print Layers (template layers never
print), placement on the imageable area, no scale / fit / custom / tiles of the paper or of the
imageable area with overlap and a tile range; printer's marks and bleed as PDF export draws
them; composite or separations (one grey plate per ink that prints, overprints honoured, spots
as process), emulsion down, negative; rendering intent and preserve numbers. print.preview lists
the pages, tiles, inks and warnings; print.setup saves the settings with the document
(Document.print_setup, one undo step).

PDF export and print share one page writer; the ink separation helpers move from the renderer to
vectorcraft_doc::inks and the range parser to vectorcraft_doc::range so print can use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:13:10 +07:00
bflatasticandClaude Opus 5.5 3d87b71ed4 M14.4: a batch that opens a new document is journaled
command.batch kept its steps in one interaction of the document active
when it began and journaled itself by committing that interaction. A
step that opened, closed or reverted a document broke both: the switch
cancelled the interaction (throwing away the steps so far), and the
commit then ran in the new document, which had none, so the batch was
never journaled and a replay was missing the new document. An error
after the switch rolled back only the active document.

- Each step runs inside an interaction of the document active when it
  starts; a switch leaves it open while a batch runs, so every document
  the batch edits gets one undo step when it succeeds.
- The batch journals itself, whichever document it ends in.
- On error everything rolls back: documents the batch opened close, the
  ones it closed or reverted come back (Session::batch_stash), each
  one's interaction is undone, and the active document and the
  Untitled-N count go back too.
- DocState::keep_interaction / undo_interaction share the undo-step and
  rollback code with commit_interaction / cancel_interaction.
- Tests: a batch that opens a new document is journaled, replays to the
  same documents and keeps one undo step in each; a failed batch closes
  the document it opened and restores the one it closed; a failed batch
  brings back a document it reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:22:36 +07:00
bflatasticandClaude Opus 5.5 766b612070 M14.4: a batch journals the dates and preferences its steps used
Session::note_journal only kept values noted by the top-level command, so
a command run as a step of command.batch lost them: a save in a batch
stamped File Info's modified date from the clock without recording it,
and object.scale's strokes/corners came from the preferences again on
replay. Replaying such a journal a second later (or with other
preferences) gave a different document.

- Session::execute_step runs a batch step with its own journal notes
  (note_depth) and returns its params with what it noted; command.batch
  writes those params into its step of the batch's journal entry (steps
  that note nothing stay as given).
- journal_for_action strips the replay-only dates from a batch's steps
  too, so an action recorded with one still dates the document when it
  plays.
- Tests: a save in a batch records its date and a replay with the
  recorded date moved lands on it, while an action leaves it out; a scale
  in a batch records Scale Strokes & Effects and replays alike with the
  preference off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:05:09 +07:00
bflatasticandClaude Opus 5.5 0312f1ea55 M14.4: the pasted-PDF fuzz test accepts a paste that refuses out-of-range art
pasted_pdf_never_panics failed at random (about one run in four at 256
cases): a mutated digit turns a curve coordinate like 245.18535 into
245018535, so the pasted art spans ~1e8 pt, beyond MAX_COORD, and
edit.paste rightly refuses it with "result would exceed the canvas",
leaving the document unchanged. The helper unwrapped that result. The
test is about never panicking, so paste_from_elsewhere now ignores an
Err from edit.paste, with a comment saying why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:05:09 +07:00
BFlat e2e36f221b Merge pull request #103 from storytold/pr/m4-eps-export
M4.76: EPS export
2026-10-05 14:02:45 +07:00
bflatasticandClaude Opus 5.5 4c4850e745 M4.76: EPS export (PostScript Level 2 and 3) with EPS Options, TIFF previews and the document embedded
New crates/eps (L3): a hand-written EPS writer. A DSC header with
%%BoundingBox and %%HiResBoundingBox (y up from the origin), %%LanguageLevel
and spot colours; paths filled and stroked with caps, joins, miter limits
and dashes (strokes a plain line can't draw as filled outlines, brushes as
their art), clipping groups, linear and radial gradients as smooth
shadings at Level 3 and stepped fills at Level 2 or with compatible
gradient printing, patterns as tiles, freeform gradients and placed images
as images (Flate with masked transparent pixels at Level 3, run-length at
Level 2), type as glyph outlines, spot colours as Separation spaces and
overprints when preserved; RGB written as CMYK on request. A TIFF preview
(1-bit or colour, transparent or opaque) behind the binary header, a PNG
thumbnail and the native document in comments (eps::native and
eps::thumbnail read them back for a later import).

document.export {format: eps} and document.exportEps: transparency is
flattened first with a flattener preset (built-in or saved) and options;
the visible art's bounds, or with useArtboards one file per artboard named
{stem}_{artboard}.eps. Export As lists EPS, then EPS Options
(ui.epsOptionsDialog for agents, remembered between exports).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:50:50 +07:00
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
1011 changed files with 247239 additions and 14055 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`.
+104
View File
@@ -0,0 +1,104 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
# The `package` job checks packaging/freebsd/package.sh (the release job runs it for real).
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
# The optional craft-fonts build input (Japanese fonts), so the tests that need it run here
# rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md).
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
# The checkout above is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
package:
name: FreeBSD 14 (package)
# The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies)
# and on demand, not on every push to main.
if: github.event_name != 'push'
runs-on: ubuntu-latest
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
# Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
mem: 12288
prepare: |
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM.
# The craft-fonts checkout is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
# Every part the package must have, the craft-fonts licences included.
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
done
- uses: actions/upload-artifact@v4
with:
name: freebsd-package
path: dist/release/*
if-no-files-found: error
+4
View File
@@ -47,6 +47,10 @@ jobs:
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.vectorcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
+93 -2
View File
@@ -63,13 +63,20 @@ jobs:
macos:
name: macOS universal
needs: version
runs-on: macos-15
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
@@ -90,6 +97,8 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
@@ -112,11 +121,21 @@ jobs:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -141,6 +160,8 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
@@ -167,6 +188,13 @@ jobs:
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
@@ -184,6 +212,9 @@ jobs:
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
@@ -201,6 +232,56 @@ jobs:
path: dist/release/*
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
runs-on: ubuntu-latest
timeout-minutes: 150
environment: release
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
# Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
if: inputs.version != ''
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and package in a FreeBSD VM
# v1.5.9, pinned by commit.
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
mem: 12288
# Passed through by name; never templated into the script.
envs: "VECTORCRAFT_VERSION VECTORCRAFT_BUILD_DATE VECTORCRAFT_BUILD_SHA"
prepare: |
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM.
# The craft-fonts checkout is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-freebsd
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
@@ -212,6 +293,13 @@ jobs:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
@@ -227,6 +315,9 @@ jobs:
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
@@ -236,7 +327,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, web]
needs: [version, macos, windows, linux, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
+95
View File
@@ -0,0 +1,95 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: vectorcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: vectorcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'VectorCraft'
foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'vectorcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok vectorcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' }
Write-Output 'ok uninstalled'
+43
View File
@@ -0,0 +1,43 @@
# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and
# weekly, so ordinary PRs never wait on it, and the weekly run shows when a dependency update starts
# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build
# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host
# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support.
name: Windows 7 x64 compatibility
on:
schedule:
- cron: "23 5 * * 1" # weekly
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
build:
runs-on: windows-2022
timeout-minutes: 90
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0
with:
arch: x64
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master
with:
toolchain: nightly-2026-10-01
components: rust-src
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows7-x64
- name: Build, check and test the portable package
shell: pwsh
run: ./packaging/windows/windows7.ps1 -Test
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: vectorcraft-windows7-x64-portable
path: dist/release/vectorcraft-windows7-x64-portable.zip
if-no-files-found: error
+2
View File
@@ -5,3 +5,5 @@ plan/
/corpus
/log
.DS_Store
# A craft-fonts checkout for CRAFT_FONTS_DIR (CI and release workflows check it out here)
/craft-fonts
+38 -5
View File
@@ -1,12 +1,13 @@
# VectorCraft — instructions for agents
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../printcraft` (Acrobat-class); same conventions.
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../pdfcraft` (Acrobat-class); same conventions.
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
@@ -21,13 +22,24 @@ 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.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- 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`).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **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).
@@ -39,4 +51,25 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and time-to-parity estimates. Update it whenever a milestone task lands.
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
- Grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. Scores are self-assessed, so err low.
- Keep the README's Status section in step with the ROADMAP headline.
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
+55 -32
View File
@@ -2,20 +2,24 @@
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`).
**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The small Latin UI/document fonts below predate that rule and stay.
**Optional build input: craft-fonts.** Builds made with `CRAFT_FONTS_DIR=<craft-fonts checkout>` (all official releases) embed the Japanese fonts listed in its [`ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md): BIZ UDPGothic Regular and Bold, Shippori Mincho Regular and BIZ UDMincho Regular (web builds: BIZ UDPGothic Regular only), all under the SIL Open Font License 1.1. They are not files in this repository; release packages carry each one's `OFL-<family>.txt`. Builds without `CRAFT_FONTS_DIR` embed none of them.
| 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 +68,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`) | |
@@ -86,9 +90,12 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-alignto-key.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-alignto-selection.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-anchor.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-anchor-smooth.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `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 +104,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 +136,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 | |
@@ -134,6 +148,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-join-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-join.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-knife.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-line-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-line.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-list-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-live-bucket.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -141,6 +156,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-mask-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-measure.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-mesh.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-mirror-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -151,11 +167,11 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-para-justify-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-justify-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-ltr.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-rtl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-path-eraser.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-pathfinder.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-pen-add.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-pen-delete.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-perspective.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-pf-crop.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-pf-divide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -173,6 +189,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-pucker.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-puppet.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rearrange.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rect-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rect-grid.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-reference-point.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-remove-brush.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -180,6 +197,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,35 +240,37 @@ 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`) | |
| `assets/icons/pen-tool.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/pen-tool-add.svg` | Lucide Icons and Contributors; badge by VectorCraft contributors | https://lucide.dev (lucide-static v1.49.0 `pen-tool`; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Lucide's pen-tool with a plus badge |
| `assets/icons/pen-tool-delete.svg` | Lucide Icons and Contributors; badge by VectorCraft contributors | https://lucide.dev (lucide-static v1.49.0 `pen-tool`; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Lucide's pen-tool with a minus badge |
| `assets/icons/pencil.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/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 +280,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 +290,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 +301,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 | |
@@ -296,6 +317,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `docs/images/shot-2-ribbons.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/ribbons.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-3-sheet.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-4-bezier.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-5-perspective.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/perspective-city.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-neon-drive.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/neon-drive.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-ribbons.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/ribbons.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-pathfinder.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/feature-sheet.vectorcraft` (artboard 1) | MIT OR Apache-2.0 | |
@@ -305,3 +327,4 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `examples/neon-drive.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
+20 -5
View File
@@ -1,12 +1,13 @@
# VectorCraft — instructions for agents
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../printcraft` (Acrobat-class); same conventions.
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../pdfcraft` (Acrobat-class); same conventions.
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
@@ -21,13 +22,24 @@ 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.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- 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`).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **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).
@@ -39,4 +51,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and time-to-parity estimates. Update it whenever a milestone task lands.
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
- Grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. Scores are self-assessed, so err low.
- Keep the README's Status section in step with the ROADMAP headline.
Generated
+522 -26
View File
@@ -121,6 +121,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -134,6 +145,21 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -191,6 +217,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -458,6 +485,15 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -489,6 +525,27 @@ dependencies = [
"piper",
]
[[package]]
name = "brotli"
version = "8.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -611,6 +668,16 @@ dependencies = [
"libc",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -714,6 +781,15 @@ dependencies = [
"libm",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -763,6 +839,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -775,6 +861,16 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -857,6 +953,7 @@ dependencies = [
"egui-wgpu",
"egui-winit",
"egui_glow",
"glow",
"glutin",
"glutin-winit",
"image",
@@ -1146,6 +1243,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -1172,6 +1275,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1309,6 +1418,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -1370,7 +1489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1386,7 +1505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1534,6 +1653,15 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1542,7 +1670,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1551,7 +1679,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1566,6 +1694,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
dependencies = [
"brotli",
"hayro-postscript",
]
@@ -1802,6 +1931,15 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -1885,6 +2023,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"
@@ -1975,6 +2119,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.189"
@@ -2067,6 +2217,16 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -2214,6 +2374,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2601,6 +2770,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -2661,6 +2840,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -2902,6 +3092,15 @@ dependencies = [
"unarray",
]
[[package]]
name = "psd"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "pxfm"
version = "0.1.30"
@@ -3364,6 +3563,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -3543,6 +3753,22 @@ 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 = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spirv"
version = "0.4.0+sdk-1.4.341.0"
@@ -3573,6 +3799,16 @@ dependencies = [
"float-cmp",
]
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.5",
"serde",
]
[[package]]
name = "subsetter"
version = "0.2.6"
@@ -3868,6 +4104,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -3886,6 +4133,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -4044,24 +4297,31 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"arboard",
"block2 0.6.2",
"clipboard-win",
"eframe",
"egui",
"image",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"rfd",
"serde_json",
"vectorcraft-engine",
"vectorcraft-ui-egui",
"webbrowser",
"winit",
"winresource",
]
[[package]]
name = "vectorcraft-brush"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4071,9 +4331,25 @@ dependencies = [
"vectorcraft-geom",
]
[[package]]
name = "vectorcraft-cad"
version = "0.5.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.1"
version = "0.5.0"
dependencies = [
"serde_json",
"vectorcraft-color",
@@ -4089,7 +4365,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4101,20 +4377,24 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"image",
"jpeg-encoder",
"kurbo 0.13.1",
"proptest",
"serde",
"serde_json",
"thiserror 2.0.21",
"tiff",
"vectorcraft-color",
"vectorcraft-geom",
"zune-jpeg",
]
[[package]]
name = "vectorcraft-effects"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4122,12 +4402,16 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-plugins",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-engine"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"log",
"proptest",
@@ -4135,24 +4419,48 @@ dependencies = [
"serde_json",
"thiserror 2.0.21",
"vectorcraft-brush",
"vectorcraft-cad",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-eps",
"vectorcraft-format",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pathops",
"vectorcraft-pdf",
"vectorcraft-plugins",
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"vectorcraft-tools",
"vectorcraft-trace",
"wat",
]
[[package]]
name = "vectorcraft-eps"
version = "0.5.0"
dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-render",
"vectorcraft-testkit",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-format"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"flate2",
"proptest",
"serde",
"serde_json",
@@ -4164,7 +4472,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4176,11 +4484,13 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"image",
"log",
"proptest",
"serde_json",
"socket2",
"vectorcraft-doc",
"vectorcraft-engine",
"vectorcraft-format",
@@ -4191,9 +4501,23 @@ dependencies = [
"vectorcraft-tools",
]
[[package]]
name = "vectorcraft-metafile"
version = "0.5.0"
dependencies = [
"image",
"kurbo 0.13.1",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-pathops"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4205,31 +4529,60 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"aes",
"flate2",
"hayro-interpret",
"hayro-syntax",
"image",
"jpeg-encoder",
"krilla",
"kurbo 0.13.1",
"log",
"md-5",
"serde",
"serde_json",
"sha2",
"skrifa 0.47.0",
"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.1"
name = "vectorcraft-plugins"
version = "0.5.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-geom",
"wasmi",
"wat",
]
[[package]]
name = "vectorcraft-render"
version = "0.5.0"
dependencies = [
"flate2",
"gif",
"image",
"jpeg-encoder",
"log",
"proptest",
"psd",
"serde_json",
"tiff",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
@@ -4239,19 +4592,25 @@ dependencies = [
"vectorcraft-text",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
"weezl",
]
[[package]]
name = "vectorcraft-svg"
version = "0.1.1"
version = "0.5.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 +4621,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4279,20 +4638,27 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
"log",
"skrifa 0.47.0",
"subsetter",
"unicode-bidi",
"unicode-general-category",
"unicode-script",
"vectorcraft-doc",
"vectorcraft-geom",
"windows-registry",
"write-fonts",
]
[[package]]
name = "vectorcraft-tools"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"image",
"kurbo 0.13.1",
"serde",
"serde_json",
@@ -4305,7 +4671,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4318,29 +4684,36 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"egui",
"egui_extras",
"image",
"log",
"serde",
"serde_json",
"toml",
"vectorcraft-cad",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-engine",
"vectorcraft-eps",
"vectorcraft-format",
"vectorcraft-geom",
"vectorcraft-pdf",
"vectorcraft-plugins",
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"vectorcraft-tools",
"wat",
]
[[package]]
name = "vectorcraft-web"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"eframe",
"egui",
@@ -4348,6 +4721,7 @@ dependencies = [
"log",
"rfd",
"vectorcraft-engine",
"vectorcraft-format",
"vectorcraft-ui-egui",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -4504,6 +4878,99 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
dependencies = [
"leb128fmt",
"wasmparser 0.261.0",
]
[[package]]
name = "wasmi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
dependencies = [
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser 0.228.0",
]
[[package]]
name = "wasmi_collections"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
dependencies = [
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
dependencies = [
"libm",
]
[[package]]
name = "wasmi_ir"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
dependencies = [
"wasmi_core",
]
[[package]]
name = "wasmparser"
version = "0.228.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
dependencies = [
"bitflags 2.13.2",
"indexmap",
]
[[package]]
name = "wasmparser"
version = "0.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
dependencies = [
"bitflags 2.13.2",
"indexmap",
"semver",
]
[[package]]
name = "wast"
version = "261.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder",
]
[[package]]
name = "wat"
version = "1.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
dependencies = [
"wast",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
@@ -4956,6 +5423,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -5224,6 +5702,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "write-fonts"
version = "0.48.1"
@@ -5320,7 +5816,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.1.1"
version = "0.5.0"
dependencies = [
"serde_json",
]
+31 -4
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.1.1"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
@@ -29,6 +29,10 @@ 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" }
vectorcraft-eps = { path = "crates/eps" }
vectorcraft-metafile = { path = "crates/metafile" }
vectorcraft-plugins = { path = "crates/plugins" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -38,15 +42,30 @@ 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"
# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32
# too. Without `wat` (text parsing is test-only) and memory64; `validate` keeps full module
# validation on; the portable dispatcher can't overflow the host stack on long-running modules.
wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "stable", "portable-dispatch"] }
# Tests: WebAssembly text -> binary for the plug-in fixtures (Apache-2.0 WITH LLVM-exception).
wat = "1.228"
# 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"] }
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
# decoders, read directly (the image crate converts CMYK to RGB).
tiff = "0.11"
zune-jpeg = "0.5"
# ui
egui = "0.36"
@@ -55,6 +74,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 +82,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.
+66 -6
View File
@@ -1,14 +1,74 @@
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.
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
under the Apache License 2.0 with the LLVM exception.
The experimental Windows 7 build (packaging/windows/windows7.ps1) compiles a modified copy of the
windows-link crate 0.2.1 (vendor/windows-link; Microsoft, MIT OR Apache-2.0) that binds CoTaskMemFree
through ole32.dll. Its license texts are in vendor/windows-link and ship in that build's ZIP; every
other build uses the unmodified crate from crates.io.
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic,
Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC and Noto Sans Arabic in the current manifest;
web builds BIZ UDPGothic Regular only) under the SIL
Open Font License 1.1. Their license texts ship with the release packages as OFL-<family>.txt.
PDF import decodes CJK text with Adobe's CMap resources (https://github.com/adobe-type-tools/cmap-resources),
compiled into the app through the hayro-cmap crate under the BSD 3-Clause License:
Copyright 1990-2023 Adobe. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
Neither the name of Adobe nor the names of its contributors may be
used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+70 -14
View File
@@ -16,13 +16,13 @@
<p align="center">
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
macOS, Windows and Linux, and in the browser via WebAssembly. Built by the ArtCraft team.
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
</p>
<p align="center">
<img alt="Status: in active development" src="https://img.shields.io/badge/status-in%20active%20development-e8573f">
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-b83a24?logo=rust&logoColor=white">
<img alt="Runs on macOS, Windows, Linux and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-555555">
<img alt="Runs on macOS, Windows, Linux, FreeBSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-555555">
<img alt="MCP server for agents" src="https://img.shields.io/badge/agents-MCP%20server-555555">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-555555">
</p>
@@ -40,7 +40,7 @@
<br>
<p align="center">
<img src="docs/images/shot-1-neon.png" alt="VectorCraft editing the Neon Drive poster: the title is selected, the Appearance panel shows its live Outer Glow, and the Properties panel shows its character settings" width="100%">
<img src="docs/images/shot-1-neon.png" alt="VectorCraft editing the Neon Drive poster: the title is selected, the Appearance panel shows the settings of its live Outer Glow, and the Properties panel shows its character settings" width="100%">
<br><sub><b>Neon Drive</b>: a Pathfinder-cut sun, live Outer Glow on the type and grid, and clipping masks · <code>examples/neon-drive.vectorcraft</code></sub>
</p>
@@ -63,8 +63,8 @@
<table>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-2-ribbons.png" alt="Three live blend ribbons of 55 to 70 steps with smooth colour, clipped to the artboard, with the Layers panel open" width="100%">
<p align="center"><sub><b>Live Blends</b>: editable key paths and smooth colour, clipped to the artboard</sub></p>
<img src="docs/images/shot-2-ribbons.png" alt="Three live blend ribbons clipped to the artboard, one selected with its key paths showing; the Layers panel lists the clip group, the blends and the selected blend's two key paths" width="100%">
<p align="center"><sub><b>Live Blends and Layers</b>: editable key paths, smooth colour, every object a row</sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-4-bezier.png" alt="Direct Selection tool showing anchor points and Bézier handles on a crescent built with Pathfinder, with the contextual task bar below it" width="100%">
@@ -72,7 +72,11 @@
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<td width="50%" valign="top">
<img src="docs/images/shot-5-perspective.png" alt="Two lit building facades drawn on the left and right planes of a two-point perspective grid at sunset, with the Perspective Selection tool and the Plane Switching Widget" width="100%">
<p align="center"><sub><b>Perspective Grid</b>: art attached to its planes stays editable in perspective · <code>examples/perspective-city.vectorcraft</code></sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-3-sheet.png" alt="Four artboards in the light UI theme: Pathfinder, Gradient Mesh, radial Repeat and Envelope Distort" width="100%">
<p align="center"><sub><b>Multiple artboards, light theme</b>: Pathfinder · Gradient Mesh · live radial Repeat · Envelope Distort · <code>examples/feature-sheet.vectorcraft</code></sub></p>
</td>
@@ -133,7 +137,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,9 +155,21 @@ 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
```
Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
On laptops with two graphics processors, VectorCraft uses the power-saving (integrated) one by default.
To use the discrete one, choose **Preferences › Performance › Graphics Processor › High Performance**
and restart, or start the app with `WGPU_POWER_PREF=high` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
respond to it yet (#491). Start the app under XWayland instead: `WAYLAND_DISPLAY= vectorcraft` (see
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
### Use it from Claude Code and other agents
Register the MCP server with Claude Code:
@@ -163,17 +180,41 @@ claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp
The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md).
For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md).
## Status
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
milestones, and honest time-to-parity estimates.
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, format, tools, engine, ui-egui, mcp, testkit}`
**Where we are (2026-10-07):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface
speaks English, Japanese, Traditional and Simplified Chinese and Spanish (and Czech and Brazilian Portuguese in the menus).
The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
**What's missing:**
- 3D and Materials;
- the Photoshop-style raster effects (Effect Gallery);
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- Variables and scripting;
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- packaging for Windows and Linux.
**Where we're going:** next is the interaction-fidelity pass alongside the raster-effects package, then 3D and
advanced type, then hardening and packaging for 1.0. The prioritized list is in
[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order).
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
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, and [`docs/releasing.md`](docs/releasing.md) how releases are built,
signed and published. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
## The Crafting Apps
@@ -187,7 +228,7 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | **Vector illustration · you are here** | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
@@ -215,13 +256,28 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
<a href="https://getartcraft.com/apps/vectorcraft">VectorCraft</a>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/vectorcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Fvectorcraft&type=date&legend=top-left)
## 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). Release builds also embed the
fonts of [craft-fonts](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) (Japanese,
Simplified Chinese and Arabic faces; SIL Open Font License 1.1).
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">
+170 -64
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-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -12,124 +12,230 @@ _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%** |
| 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 |
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents |
### Shipped so far
## Honest assessment (2026-10-05)
**In one line:** about two-thirds to three-quarters of Illustrator's features exist and work, and for everyday
vector illustration (drawing, paths, paint and appearance, SVG/PDF/print) VectorCraft is close to usable. Measured
against "a power user can't tell the difference", it is closer to 40–55%. What stands between here and "anyone can
switch" is a handful of large subsystems plus a fidelity pass, not a long tail of small fixes.
**How much to trust the numbers:** area scores are graded by the agents that built the features, from behaviour docs
(`plan/illustrator/`) and tests, not by side-by-side use of Illustrator. Treat them as upper bounds. Look & feel is
the only dimension measured against the reference app. Performance budgets haven't been re-run on an idle machine
since 2026-10-01.
### By dimension
| Dimension | Estimate | Evidence and what's missing |
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
### Where we're lacking (in priority order)
Ordered by how much each gap blocks someone from switching. Sizes are one-agent hours from the parity table.
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
isolation mode. Do it side by side with Illustrator where the owner allows. 60–90 h.
2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort,
Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at
Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h.
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
The largest single gap. 50–80 h.
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual
tate-chu-yoko), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
initial support. 35–48 h across type core and advanced.
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by pen tilt, bearing and
barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every platform. 20–32 h.
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
rulers. 15–25 h.
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, idle-machine perf budgets, Windows, Linux and
browser QA, pen tablets on Wayland and pen pressure beyond Windows (winit 0.31's tablet input, #491), accessibility, packaging. 50–80 h.
### Where we're going
- **Next:** start the interaction-fidelity pass (1) in parallel with the raster-effects package (2). Both are broad,
so they split well across agents, and they move the "can't tell the difference" number most.
- **Then:** 3D (3) and advanced type (4), each a focused milestone with its own plan in `plan/` before coding.
- **Alongside:** keep closing the stubbed menu items. Run `vectorcraft-cli perf` on an idle machine, and fix any
budget it misses before 1.0.
- **1.0:** feature parity plus the fidelity pass, hardening (8) and packaging for all three desktop OSes and the web.
**For agents:** pick work from the list above (or the milestone rows below), and write a task plan before coding.
When a task lands, update this section, the parity table and "Shipped so far" in the same PR. Keep the scores honest:
grade by behaviour against `plan/illustrator/`, not by whether a menu item exists.
## 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.
- **Automation:**
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- JSON-lines control channel with real egui pointer and keyboard injection.
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless.
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
- Actions panel that records and plays back commands.
- **UI:** Illustrator 2026 layout restyled to measured values:
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats.
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 35 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected).
- 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. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- **Tools:**
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
- **Cutting:** Eraser, Scissors, Knife.
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442).
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles; drawing then goes on.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472).
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **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.
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish also translates status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline; Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline or the em box top/right, centre or bottom/left (ICF top and bottom not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **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).
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), 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 (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
- **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.
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors. It also runs what other generators write (Discord feedback, #466, #469): Illustrator 2026's procsets (`gcheck`, `rootfont`, `currentcolorrendering`: every Illustrator 2026 EPS fell back to its preview before), cairo/Inkscape (`status`/`flushfile`, images with a mask, `ImageType 3`, reusable streams), matplotlib's Type 3 fonts, Illustrator 8 EPS (stock art's usual format: executable strings such as its gradients' `ed`, `load` by integer keys, #474), Ghostscript's `//name` immediate names, `pdfmark`, `token`, `execform` and user paths. Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- **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.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
## Milestones and estimates
| # | Milestone | Status | Est. remaining, one agent (h) |
|---|---|---|---|
| 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 |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed; Shaper, the remaining Pen modifier nuances) | 15–20 |
| 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; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| 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 |
| 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 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool done, Trim View, middle-button pan; print tiling, multiple windows, presentation polish) | 🟡 | 20–33 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
| 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 (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA 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; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
## 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, file, export, print, links and UI-chrome rows after M4 on 2026-10-05):_ 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 |
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on plain paths and polygons (and Relative rounding) | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 15% | ~55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Texture…), resolution setting, raster effects in PDF | 30–45 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish | 6–10 |
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish | 6–10 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, print tiling, Snap to Pixel/Glyph | 10–15 |
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
| File formats | 6 | 60% | EPS in/out, DXF/DWG, PSD placement, PDF raster effects/presets | 40–55 |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 45% | Asset Export panel, slices, Save for Web | 12–18 |
| Print, colour management, separations, flattener | 3 | 40% | Print dialog (platform), flattener presets, print presets | 20–30 |
| Type core | 9 | 79% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), Image Trace View modes (Outlines, Source Image) (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 82% | global/video rulers, smart-guide preference depth, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451) | 3–5 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files (Illustrator 2026 and Inkscape files open as vectors; CorelDRAW, Affinity, LibreOffice, matplotlib and Ghostscript output checked with synthetic programs only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| 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** |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 35 Preferences options stored but not yet acted on (#394: Construction and Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| 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** | | **~56%** | | **~320–500** |
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): **~55–95 h** to feature parity,
**~80–145 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.
_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
implemented except Touch Type; 51 panel modules; Illustrator-style live
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
_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
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
## Out of scope (by design or by law)
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance.
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Art off the page that a PDF does draw opens on the pasteboard.
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
+68 -22
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]... [--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]
@@ -14,11 +14,41 @@
use std::io::Write;
use std::process::ExitCode;
/// `println!` / `print!` that end the program quietly when stdout is closed
/// (`vectorcraft-cli commands | head`) instead of panicking with "failed printing to stdout:
/// Broken pipe (os error 32)".
macro_rules! outln {
($($arg:tt)*) => {{
use std::io::Write as _;
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
$crate::stdout_failed(e);
}
}};
}
macro_rules! out {
($($arg:tt)*) => {{
use std::io::Write as _;
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
$crate::stdout_failed(e);
}
}};
}
mod perf;
use serde_json::{Value, json};
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
/// ripgrep does; any other write error is reported.
fn stdout_failed(e: std::io::Error) -> ! {
if e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
eprintln!("vectorcraft-cli: can't write to stdout: {e}");
std::process::exit(1);
}
const USAGE: &str = "\
vectorcraft-cli — VectorCraft automation
@@ -28,21 +58,26 @@ 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
order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step.
Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export each FILE in the format its extension picks (see Writable formats). 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 in the format its extension picks (see Writable
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
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.
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
read from its preview and why), 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 +85,12 @@ USAGE:
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
";
/// The usage text plus the formats `document.open` reads and `document.export` writes.
fn usage() -> String {
use vectorcraft_engine::cmd::fileio::{OPEN_EXTS, export_extensions};
format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n", OPEN_EXTS.join(", ."), export_extensions().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 +102,14 @@ fn main() -> ExitCode {
Some("bench") => bench(&args[1..]),
Some("perf") => perf::run(&args[1..]),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
out!("{}", usage());
Ok(())
}
Some("-V" | "--version") => {
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
outln!("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,
@@ -105,6 +146,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
}
};
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
// The binary owns the logger, not the library: installing one here keeps an embedder that
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
vectorcraft_mcp::logging::install();
let stdin = std::io::stdin();
let stdout = std::io::stdout();
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
@@ -113,18 +157,19 @@ fn mcp(args: &[String]) -> Result<(), String> {
fn commands() -> Result<(), String> {
let mut h = Headless::new();
let v = h.call("engine.commands", json!({}))?;
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
Ok(())
}
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,17 +180,17 @@ 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}");
outln!("{r}");
Ok(())
}
fn info(args: &[String]) -> Result<(), String> {
let file = args.first().ok_or("info needs a FILE")?;
let mut h = Headless::new();
h.call("app.open", json!({"path": file})).map_err(|e| format!("open {file}: {e}"))?;
let opened = h.call("app.open", json!({"path": file})).map_err(|e| format!("open {file}: {e}"))?;
let base = h.call("engine.execute", json!({"command": "file.info", "params": {}}))?;
let doc = h.session.doc().map_err(|e| e.to_string())?.doc.clone();
let mut kinds: std::collections::BTreeMap<&'static str, usize> = Default::default();
@@ -153,8 +198,9 @@ fn info(args: &[String]) -> Result<(), String> {
let fonts = h.call("engine.execute", json!({"command": "text.fonts", "params": {}})).unwrap_or(Value::Null);
let artboards: Vec<Value> =
doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect();
let v = json!({"file": file, "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts});
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
// What didn't come in as it was (an EPS read from its preview says why).
let v = json!({"file": file, "warnings": opened["warnings"], "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts});
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
Ok(())
}
@@ -244,7 +290,7 @@ fn bench(args: &[String]) -> Result<(), String> {
* vectorcraft_geom::Affine::scale(z)
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
let opts = vectorcraft_render::RenderOptions::default();
println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
outln!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
for threads in [vectorcraft_render::default_threads(), 0] {
let mut r = vectorcraft_render::Renderer::new();
r.threads = threads;
@@ -253,7 +299,7 @@ fn bench(args: &[String]) -> Result<(), String> {
for _ in 0..iters {
r.render(&doc, w, h, view, &opts);
}
println!(
outln!(
" threads {threads}: {:.1} ms/frame (drawn {}, culled {})",
t.elapsed().as_secs_f64() * 1000.0 / iters as f64,
r.stats.drawn,
+29 -16
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.
@@ -81,14 +81,14 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let load = load_average();
println!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
if noisy {
println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
}
let t = Instant::now();
let doc = synthetic(n);
println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
let doc = synthetic(n)?;
outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
let mut rows: Vec<(&str, f64, f64)> = vec![];
let opts = RenderOptions::default();
@@ -123,30 +123,43 @@ 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");
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
for (name, ms, budget) in &rows {
let ok = ms <= budget;
over += usize::from(!ok);
println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
}
match (over, noisy) {
(0, _) => Ok(()),
(_, true) => {
println!("{over} over budget, but the machine is busy: re-run when idle.");
outln!("{over} over budget, but the machine is busy: re-run when idle.");
Ok(())
}
_ => Err(format!("{over} budget(s) exceeded")),
+165
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};
@@ -80,3 +82,166 @@ fn mcp_headless_over_stdio() {
assert_eq!(replies[2]["result"]["isError"], false);
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
}
/// End-to-end protocol sweep over stdio against the compiled binary. The in-process
/// tests drive `Server::handle_line` directly, so the binary's own wiring (argument
/// parsing, logger install, stdout pollution, notification order) is untested: this
/// drives the real `mcp --headless` process through prompts, completions, resource
/// templates, logging and the error taxonomy instead.
#[test]
fn mcp_protocol_over_stdio() {
use std::io::{BufRead, BufReader};
let mut child =
Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let mut notes = vec![];
fn send(stdin: &mut std::process::ChildStdin, v: Value) {
writeln!(stdin, "{v}").unwrap();
}
// Next reply with this id; notifications (no id) are stashed for later.
fn recv(lines: &mut std::io::Lines<BufReader<std::process::ChildStdout>>, notes: &mut Vec<Value>, id: u64) -> Value {
loop {
let line = lines.next().unwrap().unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
if v.get("id").and_then(|i| i.as_u64()) == Some(id) {
return v;
}
notes.push(v);
}
}
send(
&mut stdin,
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
);
let r = recv(&mut lines, &mut notes, 1);
assert_eq!(r["result"]["serverInfo"]["name"], "vectorcraft");
assert!(r["result"]["capabilities"]["prompts"].is_object(), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}));
let r = recv(&mut lines, &mut notes, 2);
assert!(r["result"]["prompts"].as_array().is_some_and(|p| !p.is_empty()), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}));
let r = recv(&mut lines, &mut notes, 3);
assert!(r["result"]["messages"][0]["content"]["text"].as_str().is_some_and(|t| t.contains("a jazz festival")), "{r}");
send(
&mut stdin,
json!({"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"export-set"},"argument":{"name":"formats","value":"sv"}}}),
);
let r = recv(&mut lines, &mut notes, 4);
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 2));
send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
send(
&mut stdin,
json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"rectangle","x":10,"y":10,"width":60,"height":40}}}),
);
let r = recv(&mut lines, &mut notes, 7);
assert_eq!(r["result"]["isError"], false, "{r}");
let id: i64 = serde_json::from_str::<Value>(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].as_i64().unwrap();
send(&mut stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}}));
let r = recv(&mut lines, &mut notes, 8);
let body: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(body["id"].as_i64(), Some(id), "{body}");
// Unknown resources (-32002) and bad template values (-32602) stay apart.
send(&mut stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }}));
assert_eq!(recv(&mut lines, &mut notes, 9)["error"]["code"], -32002);
send(&mut stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}}));
assert_eq!(recv(&mut lines, &mut notes, 10)["error"]["code"], -32602);
// Large containers slice through run_command document.node: a truncated level reports
// childCount, and a bad limit is a tool error, not a crash or a full read.
send(
&mut stdin,
json!({"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"ellipse","x":90,"y":10,"width":30,"height":30}}}),
);
assert_eq!(recv(&mut lines, &mut notes, 20)["result"]["isError"], false);
let run = |id: u64, command: &str, params: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}});
send(&mut stdin, run(21, "select.all", json!({})));
recv(&mut lines, &mut notes, 21);
send(&mut stdin, run(22, "object.group", json!({})));
let r = recv(&mut lines, &mut notes, 22);
let group = serde_json::from_str::<Value>(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone();
send(&mut stdin, run(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1})));
let r = recv(&mut lines, &mut notes, 23);
let sliced: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(sliced["children"].as_array().map(Vec::len), Some(1), "{sliced}");
assert_eq!(sliced["childCount"], 2, "{sliced}");
send(&mut stdin, run(24, "document.node", json!({"id": group, "summary": true, "depth": -1})));
assert_eq!(recv(&mut lines, &mut notes, 24)["result"]["isError"], true);
// Skeleton then drill: a depth-0 inspect is the layer skeleton with counts, and
// document.find locates nodes without dumping the tree.
send(&mut stdin, run(25, "document.inspect", json!({"depth": 0})));
let r = recv(&mut lines, &mut notes, 25);
let skel: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert!(skel["layers"][0].get("children").is_none(), "{skel}");
assert!(skel["artboards"].as_array().is_some_and(|a| !a.is_empty()), "{skel}");
send(&mut stdin, run(26, "document.find", json!({"kind": "ellipse"})));
let r = recv(&mut lines, &mut notes, 26);
let found: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(found["total"], 1, "{found}");
assert!(found["matches"][0]["path"].as_array().is_some_and(|p| !p.is_empty()), "{found}");
send(&mut stdin, run(27, "document.find", json!({})));
assert_eq!(recv(&mut lines, &mut notes, 27)["result"]["isError"], true);
// The binary installed its logger: records arrive as notifications/message, and only
// after the client asked for them.
send(&mut stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}}));
assert!(recv(&mut lines, &mut notes, 11).get("error").is_none());
send(&mut stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty()));
// Malformed input is a parse error, and the server keeps serving afterwards.
stdin.write_all(b"{not json\n").unwrap();
let parse_err: Value = loop {
let line = lines.next().unwrap().unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
if v.get("error").is_some() {
break v;
}
notes.push(v);
};
assert_eq!(parse_err["error"]["code"], -32700);
assert!(parse_err["id"].is_null(), "{parse_err}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}}));
assert!(recv(&mut lines, &mut notes, 13).get("error").is_none());
drop(stdin);
// Drain whatever the debug window queued; every line off the protocol stream parses.
for line in lines.by_ref() {
let line = line.unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
notes.push(v);
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count();
assert!(messages > 0, "no log records after logging/setLevel: {notes:?}");
}
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
/// stdout: Broken pipe (os error 32)" and exit status 101.
#[test]
fn closed_stdout_ends_quietly() {
use std::io::Read;
let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut first = [0u8; 16];
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
drop(child.stdout.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
}
+95
View File
@@ -0,0 +1,95 @@
//! 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}");
}
/// `info` reports the import warnings: an EPS read only up to an error names it.
#[test]
fn info_reports_why_an_eps_read_partly() {
let eps = tmp("partial.eps");
std::fs::write(&eps, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 10 10 rectfill frobnicate\n%%EOF\n").unwrap();
let v = info(&eps);
let w = v["warnings"][0].as_str().unwrap_or_default();
assert!(w.contains("`frobnicate`"), "{v}");
assert_eq!(v["kinds"]["Path"], 1, "{v}");
}
+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};
+2 -1
View File
@@ -16,6 +16,7 @@ path = "src/main.rs"
[dependencies]
vectorcraft-engine = { workspace = true }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-format = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -26,4 +27,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", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator"] }
+122
View File
@@ -0,0 +1,122 @@
//! The browser's Web Locks (`navigator.locks`), which tell Data Recovery whether another tab still
//! runs ([`vectorcraft_engine::cmd::recovery::RecoveryStore::announce`]). A tab holds a lock named
//! after its recovery area for as long as it lives; the browser releases it only when the tab is
//! gone (closed, crashed or reloaded), not while its timers are paused in the background, which a
//! heartbeat can't tell apart.
//!
//! web-sys has Web Locks only as an unstable API, so they are reached through `js_sys::Reflect`.
//! Pages that aren't secure (plain `http://` other than localhost) and older browsers have none:
//! heartbeats alone decide there.
use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
use js_sys::{Function, Promise, Reflect};
use vectorcraft_engine::cmd::recovery::Hold;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// How often the snapshot of the locks held is refreshed (ms).
const REFRESH_MS: i32 = 10_000;
/// How old a snapshot may be and still be trusted (ms). A lock asked for after the snapshot was
/// taken comes with a heartbeat, which stays fresh for at least three minutes, so anything under
/// that is safe; an older snapshot counts every area as held.
const FRESH_MS: f64 = 60_000.0;
thread_local! {
/// What releases each lock this tab holds, by name: it resolves the promise the lock is held
/// by (wasm runs on one thread).
static RELEASE: RefCell<BTreeMap<String, Function>> = const { RefCell::new(BTreeMap::new()) };
}
/// The locks of the page, named `<prefix><area>`.
#[derive(Clone)]
pub struct WebLocks {
prefix: &'static str,
/// The areas whose locks were held at the last snapshot, and when it was asked for (ms since
/// the epoch). `navigator.locks.query()` is asynchronous, so the snapshot is refreshed every
/// [`REFRESH_MS`] and read from here.
seen: Arc<Mutex<(f64, BTreeSet<String>)>>,
}
/// `navigator.locks`, if the browser has it here.
fn manager() -> Option<JsValue> {
let locks = Reflect::get(&web_sys::window()?.navigator(), &"locks".into()).ok()?;
locks.is_object().then_some(locks)
}
/// Method `name` of `obj`.
fn method(obj: &JsValue, name: &str) -> Option<Function> {
Reflect::get(obj, &name.into()).ok()?.dyn_into().ok()
}
impl WebLocks {
/// The page's Web Locks with a first snapshot of them, kept fresh from then on; `None` when
/// the browser has none or won't say which are held (heartbeats alone decide then).
pub async fn start(prefix: &'static str) -> Option<Self> {
let locks = Self { prefix, seen: Arc::new(Mutex::new((f64::NEG_INFINITY, BTreeSet::new()))) };
locks.refresh().await?;
let again = locks.clone();
let tick = Closure::<dyn FnMut()>::new(move || {
let locks = again.clone();
wasm_bindgen_futures::spawn_local(async move {
// A failed refresh leaves the snapshot to age, and every area counts as held.
let _ = locks.refresh().await;
});
});
web_sys::window()?.set_interval_with_callback_and_timeout_and_arguments_0(tick.as_ref().unchecked_ref(), REFRESH_MS).ok()?;
// The timer lives as long as the page.
tick.forget();
Some(locks)
}
/// Take a new snapshot of the locks held.
async fn refresh(&self) -> Option<()> {
let m = manager()?;
let asked = js_sys::Date::now();
let query: Promise = method(&m, "query")?.call0(&m).ok()?.dyn_into().ok()?;
let snapshot = wasm_bindgen_futures::JsFuture::from(query).await.ok()?;
let held = js_sys::Array::from(&Reflect::get(&snapshot, &"held".into()).ok()?);
let areas = held
.iter()
.filter_map(|l| Reflect::get(&l, &"name".into()).ok()?.as_string())
.filter_map(|n| n.strip_prefix(self.prefix).map(str::to_string))
.collect();
*self.seen.lock().unwrap_or_else(|e| e.into_inner()) = (asked, areas);
Some(())
}
/// Does a tab hold `area`'s lock? Yes when the last snapshot is too old to tell.
pub fn held(&self, area: &str) -> bool {
let seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
js_sys::Date::now() - seen.0 > FRESH_MS || seen.1.contains(area)
}
/// Hold `area`'s lock until the returned hold is dropped (or the page goes).
pub fn hold(&self, area: &str) -> Option<Hold> {
let m = manager()?;
let name = format!("{}{area}", self.prefix);
// The lock is held until this promise resolves: when the hold is dropped.
let mut release = None;
let until = Promise::new(&mut |resolve, _| release = Some(resolve));
let release = release?;
let granted = Closure::once_into_js(move |_lock: JsValue| until);
method(&m, "request")?.call2(&m, &name.clone().into(), &granted).ok()?;
RELEASE.with(|r| r.borrow_mut().insert(name.clone(), release));
Some(Box::new(LockHold(name)))
}
}
/// A lock this tab holds (by name), released when dropped.
struct LockHold(String);
impl Drop for LockHold {
fn drop(&mut self) {
if let Some(resolve) = RELEASE.with(|r| r.borrow_mut().remove(&self.0)) {
// Resolving can't fail in a way worth reporting: the lock goes with the page anyway.
let _ = resolve.call0(&JsValue::NULL);
}
}
}
+4 -1
View File
@@ -11,8 +11,11 @@
//! - Save / Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//!
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
//! language code) stands in for the browser's languages when the interface language is Automatic.
#[cfg(target_arch = "wasm32")]
mod locks;
#[cfg(target_arch = "wasm32")]
mod web;
+483 -69
View File
@@ -1,105 +1,420 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
//! The browser shell: web `Services`, drag-and-drop, pasted pictures and files, the eframe web
//! runner, and starting again with new graphics when the page loses them (#369).
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::i18n;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
use crate::locks::WebLocks;
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)>>>;
/// A picture or file pasted into the page, read and waiting for the next frame with the modifiers
/// held at the paste, and the context to wake for it (the current runner's).
#[derive(Default)]
struct Pasted {
flavour: Option<(Flavour, egui::Modifiers)>,
ctx: Option<egui::Context>,
}
const CANVAS_ID: &str = "vectorcraft_canvas";
const LOADING_ID: &str = "vectorcraft_loading";
/// How many times the page starts again with new graphics after losing them before it gives up
/// (a GPU that keeps failing).
const MAX_RESTARTS: u32 = 3;
/// What the page keeps while the app runs, across graphics restarts.
#[derive(Clone)]
struct Host {
runner: eframe::WebRunner,
inbox: Inbox,
place_inbox: PlaceInbox,
drag: DragPos,
pasted: Rc<RefCell<Pasted>>,
/// The page's Web Locks for Data Recovery, started once before the first frame: a graphics
/// restart keeps them, so the tab stays the owner of its recovery area.
locks: Option<WebLocks>,
/// Graphics restarts so far.
restarts: u32,
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
return;
let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
detect_language();
wasm_bindgen_futures::spawn_local(async move {
// Before the first frame, which looks for copies a crash left behind.
let locks = WebLocks::start(RECOVERY_PREFIX).await;
let host = Host {
runner: eframe::WebRunner::new(),
inbox: Arc::default(),
place_inbox: Arc::default(),
drag: DragPos::default(),
pasted: Rc::default(),
locks,
restarts: 0,
};
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let result = eframe::WebRunner::new()
.start(
canvas,
options,
Box::new(move |cc| {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
track_paste(&host.pasted);
run(host, canvas, None).await;
});
}
/// An app whose graphics were lost, on its way to new ones.
struct Moving {
app: VectorcraftApp,
/// Its unsaved changes are kept in recovery copies ([`VectorcraftApp::graphics_lost`]).
kept: bool,
}
/// Run the app on `canvas`: the `moving` one when starting again after losing the graphics, else
/// a new one.
async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Moving>) {
track_drag(&canvas, &host.drag);
let kept = moving.as_ref().map(|m| m.kept);
// The app until the new runner takes it (still here if starting fails before).
let slot = Rc::new(RefCell::new(moving.map(|m| m.app)));
let (runner, taken, page) = (host.runner.clone(), slot.clone(), canvas.clone());
let result = runner
.start(
canvas,
web_options(),
Box::new(move |cc| {
let ctx = &cc.egui_ctx;
let loss = GraphicsLoss::default();
watch_canvas(&page, &loss, ctx);
if let Some(rs) = &cc.wgpu_render_state {
log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
let (loss, ctx) = (loss.clone(), ctx.clone());
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
host.pasted.borrow_mut().ctx = Some(ctx.clone());
let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone());
let app = match taken.borrow_mut().take() {
Some(mut app) => {
// The old services asked the old context for frames.
app.services = services;
app.status("The graphics were lost and have been restarted");
app
}
let inbox: Inbox = Arc::default();
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
Ok(Box::new(WebShell { app, inbox }))
}),
)
.await;
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
Err(e) => el.set_inner_html(&format!("<p>VectorCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
None => VectorcraftApp::new(Session::new(), services),
};
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
}),
)
.await;
match (result, kept) {
(Ok(()), _) => {
if let Some(el) = element(LOADING_ID) {
el.remove();
}
}
});
(Err(e), None) => notice(&[&format!("VectorCraft failed to start: {}", js_err(e)), "A browser with WebGPU or WebGL2 is required."]),
(Err(e), Some(kept)) => {
log::error!("vectorcraft-web: couldn't restart the graphics: {}", js_err(e));
give_up(slot.borrow_mut().take(), kept);
}
}
}
/// The runner's options: wgpu (WebGPU where available, else WebGL2; `?webgl` forces WebGL2).
fn web_options() -> eframe::WebOptions {
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
options
}
/// Tell the UI the browser's languages (`?lang=es` in the address first, as `VECTORCRAFT_LOCALE`
/// does on the desktop) before the first frame, so the Automatic interface language follows the
/// browser; and show the loading text in that language.
fn detect_language() {
let mut tags: Vec<String> = query_param("lang").into_iter().collect();
if let Some(navigator) = web_sys::window().map(|w| w.navigator()) {
tags.extend(navigator.languages().iter().filter_map(|v| v.as_string()));
tags.extend(navigator.language());
}
i18n::set_system_locales(tags.as_slice());
if let Some(el) = element(LOADING_ID) {
el.set_text_content(Some(i18n::message(i18n::system_lang(), "Loading VectorCraft…").as_str()));
}
}
/// The value of `name=` in the address's query, if given.
fn query_param(name: &str) -> Option<String> {
let query = query();
query.trim_start_matches('?').split('&').find_map(|kv| kv.strip_prefix(name)?.strip_prefix('=').map(str::to_string)).filter(|v| !v.is_empty())
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn element(id: &str) -> Option<web_sys::Element> {
web_sys::window()?.document()?.get_element_by_id(id)
}
/// Follow file drags over `canvas` into `pos`: drag events carry the pointer position, which egui
/// doesn't get during a drag.
fn track_drag(canvas: &web_sys::HtmlCanvasElement, pos: &DragPos) {
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 canvas.
on_drag.forget();
}
/// Take pictures and files pasted into the page (egui reads a paste's text only): a paste without
/// text hands the first of its files that is art (a bitmap, SVG, PDF, a metafile) to the next
/// frame. Text, SVG markup among it, is left to egui.
fn track_paste(pasted: &Rc<RefCell<Pasted>>) {
let Some(window) = web_sys::window() else { return };
let pasted = pasted.clone();
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
let Some(data) = e.clipboard_data() else { return };
let Some(files) = data.files().filter(|f| f.length() > 0) else { return };
if data.get_data("text").is_ok_and(|t| !t.is_empty()) {
return;
}
e.prevent_default();
let held = pasted.borrow().ctx.as_ref().map(|c| c.input(|i| i.modifiers)).unwrap_or_default();
let files: Vec<web_sys::File> = (0..files.length()).filter_map(|i| files.get(i)).collect();
let pasted = pasted.clone();
wasm_bindgen_futures::spawn_local(async move {
for file in files {
let bytes = match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buf) => js_sys::Uint8Array::new(&buf).to_vec(),
Err(e) => {
log::error!("couldn't read the pasted file {}: {}", file.name(), js_err(e));
continue;
}
};
if let Some(mime) = file_flavour(&file.name(), bytes.get(..FILE_HEAD).unwrap_or(&bytes), &PASTE_ORDER) {
let mut p = pasted.borrow_mut();
p.flavour = Some((Flavour { mime, data: bytes }, held));
if let Some(ctx) = &p.ctx {
ctx.request_repaint();
}
return;
}
}
});
});
// Captured before eframe's own listener, which stops the event.
if let Err(e) = window.add_event_listener_with_callback_and_bool("paste", on_paste.as_ref().unchecked_ref(), true) {
log::error!("couldn't follow paste events: {e:?}");
}
// The listener lives as long as the page.
on_paste.forget();
}
/// Report the loss of `canvas`'s WebGL context to `loss` (WebGPU devices report theirs through
/// wgpu).
fn watch_canvas(canvas: &web_sys::HtmlCanvasElement, loss: &GraphicsLoss, ctx: &egui::Context) {
let (loss, ctx) = (loss.clone(), ctx.clone());
let on_lost = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(move |_: web_sys::Event| {
loss.report(&ctx, "the WebGL context was lost");
});
if let Err(e) = canvas.add_event_listener_with_callback("webglcontextlost", on_lost.as_ref().unchecked_ref()) {
log::error!("couldn't watch for a lost WebGL context: {e:?}");
}
// The listener lives as long as the canvas.
on_lost.forget();
}
/// A new canvas in place of `old`, whose graphics were lost (a canvas whose WebGL context is lost
/// can't get another).
fn fresh_canvas(old: &web_sys::HtmlCanvasElement) -> Result<web_sys::HtmlCanvasElement, String> {
let new: web_sys::HtmlCanvasElement = old.clone_node().map_err(js_err)?.dyn_into().map_err(|_| "not a canvas")?;
old.replace_with_with_node_1(&new).map_err(js_err)?;
Ok(new)
}
/// The graphics are lost for good: the dead canvas goes (it would show a stale picture), the app
/// (if still here) lets go of its recovery copies so the reloaded page offers them at once, and
/// the page says what to do.
fn give_up(app: Option<VectorcraftApp>, kept: bool) {
if let Some(mut app) = app {
recovery::leave(&mut app.session);
}
if let Some(canvas) = element(CANVAS_ID) {
canvas.remove();
}
notice(&[
"The graphics were lost and couldn't be restarted.",
if kept {
"Your unsaved changes are kept in this browser: reload the page to get them back."
} else {
"Reload the page to go on (changes not saved yet may be lost)."
},
]);
}
/// Show `lines` in the middle of the page (where the loading message was).
fn notice(lines: &[&str]) {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let el = match document.get_element_by_id(LOADING_ID) {
Some(el) => el,
None => {
let Ok(el) = document.create_element("div") else { return };
el.set_id(LOADING_ID);
let Some(body) = document.body() else { return };
if let Err(e) = body.append_child(&el) {
log::error!("couldn't show a message: {e:?}");
}
el
}
};
el.set_text_content(None);
for line in lines {
if let Ok(p) = document.create_element("p") {
p.set_text_content(Some(i18n::message(i18n::system_lang(), line).as_str()));
// Best effort: the log has the message too.
el.append_child(&p).ok();
}
}
}
/// 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.
/// When the graphics are lost it hands the app to a new runner on a new canvas.
struct WebShell {
app: VectorcraftApp,
inbox: Inbox,
/// `None` once handed on.
app: Option<VectorcraftApp>,
host: Host,
canvas: web_sys::HtmlCanvasElement,
loss: GraphicsLoss,
}
impl WebShell {
/// The graphics were lost (`why`): keep recovery copies, then start again on a new canvas
/// with the same app once this frame is over (the runner is busy with it until then).
fn restart(&mut self, why: &str) {
let Some(mut app) = self.app.take() else { return };
let kept = app.graphics_lost(why);
let mut host = self.host.clone();
host.restarts += 1;
let canvas =
if host.restarts > MAX_RESTARTS { Err(format!("the graphics were lost {MAX_RESTARTS} times")) } else { fresh_canvas(&self.canvas) };
match canvas {
Ok(canvas) => wasm_bindgen_futures::spawn_local(run(host, canvas, Some(Moving { app, kept }))),
Err(e) => {
log::error!("vectorcraft-web: giving up on the graphics: {e}");
let runner = host.runner;
wasm_bindgen_futures::spawn_local(async move { runner.destroy() });
give_up(Some(app), kept);
}
}
}
}
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();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
if let Some(why) = self.loss.take() {
self.restart(&why);
}
self.app.logic(ctx);
let Some(app) = &mut self.app else { return };
let pasted = self.host.pasted.borrow_mut().flavour.take();
if let Some((f, held)) = pasted {
app.paste_from_host(ctx, f, held);
}
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
let at = self.host.drag.take();
let z = ctx.zoom_factor();
let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
let target = app.drop_target(&name, pos, shift);
let (inbox, place_inbox, ctx) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
match f.bytes_async().await {
Ok(bytes) => {
match target {
DropTarget::Place(d) => {
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) })
}
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}"),
}
});
}
}
app.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
if let Some(app) = &mut self.app {
app.raw_input_hook(raw);
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
if let Some(app) = &mut self.app {
app.ui(ui);
}
}
}
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option<WebLocks>) -> 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;
@@ -113,21 +428,71 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
}
})),
inbox: Some(inbox),
recovery_store: Some(Arc::new(BrowserStore { locks })),
// File → Print: the browser's print dialog.
print: Some(Box::new(BrowserPrint)),
..Default::default()
}
}
/// Data Recovery's store on the web: the browser's local storage (kept across visits and shared by
/// the site's tabs), each entry as base64 under [`RECOVERY_PREFIX`]`<area>/<name>`. It has no
/// locks: each tab holds its area with a heartbeat, judged by the browser's clock, and with a Web
/// Lock named after it where the browser has them (`locks`), which a tab whose timers are paused
/// in the background keeps.
struct BrowserStore {
locks: Option<WebLocks>,
}
const RECOVERY_PREFIX: &str = "vectorcraft-recovery/";
fn js_err(e: wasm_bindgen::JsValue) -> String {
format!("{e:?}")
}
fn local_storage() -> Result<web_sys::Storage, String> {
web_sys::window().ok_or("no window")?.local_storage().map_err(js_err)?.ok_or_else(|| "browser storage is turned off".into())
}
impl RecoveryStore for BrowserStore {
fn list(&self) -> Result<Vec<String>, String> {
let s = local_storage()?;
let n = s.length().map_err(js_err)?;
Ok((0..n).filter_map(|i| s.key(i).ok().flatten()).filter_map(|k| k.strip_prefix(RECOVERY_PREFIX).map(str::to_string)).collect())
}
fn read(&self, name: &str) -> Result<Vec<u8>, String> {
let text = local_storage()?.get_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)?;
text.and_then(|t| vectorcraft_format::base64_decode(&t)).ok_or_else(|| format!("{name}: no such recovery entry"))
}
fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
local_storage()?
.set_item(&format!("{RECOVERY_PREFIX}{name}"), &vectorcraft_format::base64_encode(bytes))
.map_err(|e| format!("browser storage is full or turned off: {}", js_err(e)))
}
fn remove(&self, name: &str) -> Result<(), String> {
local_storage()?.remove_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)
}
fn location(&self) -> String {
"browser storage".into()
}
fn now(&self) -> Option<i64> {
Some((js_sys::Date::now() / 1000.0) as i64)
}
fn announce(&self, area: &str) -> Option<Hold> {
self.locks.as_ref()?.hold(area)
}
fn announced(&self, area: &str) -> Option<bool> {
self.locks.as_ref().map(|l| l.held(area))
}
}
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
let window = web_sys::window().ok_or("no window")?;
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));
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 url = blob_url(bytes, fileio::format_for_name(&name).map_or("application/octet-stream", |f| f.mime))?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(&name);
@@ -144,13 +509,62 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
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",
/// An object URL of a blob of `bytes` with media type `mime` (revoke it when done).
fn blob_url(bytes: &[u8], mime: &str) -> Result<String, String> {
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime);
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js_err)?;
web_sys::Url::create_object_url_with_blob(&blob).map_err(js_err)
}
/// File → Print in the browser: the job's PDF in a hidden frame, printed through the browser's
/// print dialog (which picks the printer).
struct BrowserPrint;
/// The frame holding the last print job (the next job replaces it).
const PRINT_FRAME_ID: &str = "vectorcraft-print-frame";
impl PrintService for BrowserPrint {
fn printers(&mut self) -> Vec<Printer> {
// Browsers don't tell pages about printers: their print dialog lists them.
vec![]
}
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
print_pdf(job.pdf)?;
Ok(format!("Printing “{}”: pick the printer in the browser's print dialog", job.title))
}
}
fn print_pdf(pdf: &[u8]) -> Result<(), String> {
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
// The previous job's frame and its blob go first.
if let Some(old) = document.get_element_by_id(PRINT_FRAME_ID) {
if let Some(url) = old.get_attribute("src") {
web_sys::Url::revoke_object_url(&url).ok();
}
old.remove();
}
let url = blob_url(pdf, "application/pdf")?;
let frame: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js_err)?.dyn_into().map_err(|_| "not a frame")?;
frame.set_id(PRINT_FRAME_ID);
// Out of sight but laid out: browsers don't print a frame that isn't.
let style = frame.style();
for (k, v) in [("position", "fixed"), ("right", "0"), ("bottom", "0"), ("width", "0"), ("height", "0"), ("border", "0")] {
style.set_property(k, v).map_err(js_err)?;
}
let loaded = frame.clone();
let onload = wasm_bindgen::closure::Closure::once_into_js(move || {
if let Some(w) = loaded.content_window() {
w.focus().ok();
if let Err(e) = w.print() {
log::error!("printing failed: {e:?}");
}
}
});
frame.set_onload(Some(onload.unchecked_ref()));
frame.set_src(&url);
document.body().ok_or("no body")?.append_child(&frame).map_err(js_err)?;
Ok(())
}
+21 -2
View File
@@ -9,21 +9,40 @@ default-run = "vectorcraft"
[lints]
workspace = true
[features]
default = ["wgpu", "accessibility"]
wgpu = ["eframe/wgpu"]
accessibility = ["eframe/accesskit"]
# Build with --no-default-features --features windows7 and the win7 Rust target.
windows7 = ["eframe/glow"]
[dependencies]
vectorcraft-engine = { workspace = true }
vectorcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { workspace = true }
# wayland-data-control: native Wayland clipboard (Linux; X11 through XWayland only sees what X11 apps copied).
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
image = { workspace = true }
webbrowser = { workspace = true }
# The window's monitors, size and position (the same winit eframe runs on).
winit = { version = "0.30", default-features = false }
[target.'cfg(windows)'.dependencies]
clipboard-win = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSPasteboard", "NSResponder", "NSTextInputContext"] }
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
[build-dependencies]
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
+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(())
}
+549
View File
@@ -0,0 +1,549 @@
//! 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, a bitmap as PNG, else as the device-independent bitmap that
//! screenshots and most apps copy (`CF_DIBV5`, `CF_DIB`; the system makes them of a `CF_BITMAP`).
//! - 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 (on macOS the pasteboard's PNG or TIFF
//! as they are, as screenshots copy them). On Wayland, arboard's data-control
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
//! copied), falling back to X11 where the compositor lacks the protocol.
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, a
//! metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
use std::io::{Cursor, Read};
use std::path::PathBuf;
use vectorcraft_engine::cmd::clipboard::{BITMAP, FILE_HEAD, Flavour, PNG, TEXT, file_flavour};
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())
}
/// Most pixels a side of a bitmap another app put on the clipboard (untrusted data).
const MAX_SIDE: u32 = 32_768;
fn decode_png(png: &[u8]) -> Option<image::RgbaImage> {
Some(image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?.to_rgba8())
}
/// `img` as a PNG flavour.
fn png_flavour(img: &image::RgbaImage) -> Option<Flavour> {
let mut data = vec![];
img.write_to(&mut Cursor::new(&mut data), image::ImageFormat::Png).ok()?;
Some(Flavour { mime: PNG, data })
}
/// A Windows device-independent bitmap (`CF_DIB`, `CF_DIBV5`: a BITMAPINFO header, the colour
/// masks of a BITMAPINFOHEADER, the colour table, then the pixels) as a BMP file, whose header
/// says where the pixels start. `None` when the header is cut short or out of range.
#[cfg_attr(not(windows), allow(dead_code))]
fn dib_file(dib: &[u8]) -> Option<Vec<u8>> {
const BI_BITFIELDS: u32 = 3;
const BI_ALPHABITFIELDS: u32 = 6;
let u16_at = |i: usize| Some(u16::from_le_bytes(dib.get(i..i + 2)?.try_into().ok()?));
let u32_at = |i: usize| Some(u32::from_le_bytes(dib.get(i..i + 4)?.try_into().ok()?));
let header = u32_at(0)?;
// BITMAPCOREHEADER has 16-bit fields and 3-byte colours; the others 32-bit fields, 4-byte
// colours and a count of them (0: all a palette of the depth has), the 40-byte one its masks
// after it.
let (bits, used, entry, masks) = match header {
12 => (u16_at(10)?, 0, 3, 0),
_ => {
let masks = match (header, u32_at(16)?) {
(40, BI_BITFIELDS) => 12,
(40, BI_ALPHABITFIELDS) => 16,
_ => 0,
};
(u16_at(14)?, u32_at(32)?, 4, masks)
}
};
let colours = match used {
0 if bits <= 8 => 1 << bits,
n if n <= 256 => n,
_ => return None,
};
let offset = [header, masks, colours * entry].into_iter().try_fold(14u32, u32::checked_add)?;
let size = u32::try_from(dib.len()).ok()?.checked_add(14)?;
if offset > size {
return None;
}
let mut bmp = Vec::with_capacity(dib.len() + 14);
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&size.to_le_bytes());
bmp.extend_from_slice(&[0; 4]);
bmp.extend_from_slice(&offset.to_le_bytes());
bmp.extend_from_slice(dib);
Some(bmp)
}
/// A device-independent bitmap as an image, at most [`MAX_SIDE`] pixels a side. It is opaque when
/// its alpha is zero throughout: screenshots and most apps leave the fourth byte of a 32-bit pixel
/// at zero, even under an alpha mask.
#[cfg_attr(not(windows), allow(dead_code))]
fn dib_image(dib: &[u8]) -> Option<image::RgbaImage> {
let mut reader = image::ImageReader::with_format(Cursor::new(dib_file(dib)?), image::ImageFormat::Bmp);
let mut limits = image::Limits::default();
(limits.max_image_width, limits.max_image_height) = (Some(MAX_SIDE), Some(MAX_SIDE));
reader.limits(limits);
let mut img = reader.decode().ok()?.into_rgba8();
if img.pixels().all(|p| p.0[3] == 0) {
img.pixels_mut().for_each(|p| p.0[3] = u8::MAX);
}
Some(img)
}
/// The first of `paths` (files copied in a file manager) that pastes as one of `mimes`, read
/// whole. Only its first bytes are read to tell, so other files cost little.
fn copied_file(paths: Vec<PathBuf>, mimes: &[&'static str]) -> Option<Flavour> {
paths.into_iter().find_map(|path| {
// text/uri-list lines end in CRLF (RFC 2483), but arboard splits them on LF only.
let path = match path.to_str() {
Some(p) if p.ends_with('\r') => PathBuf::from(p.trim_end_matches('\r')),
_ => path,
};
let mut head = Vec::with_capacity(FILE_HEAD);
std::fs::File::open(&path).ok()?.take(FILE_HEAD as u64).read_to_end(&mut head).ok()?;
let mime = file_flavour(&path.to_string_lossy(), &head, mimes)?;
let data = std::fs::read(&path).ok()?;
(!data.is_empty()).then_some(Flavour { mime, data })
})
}
#[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"],
_ => &[],
}
}
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
/// other or a `CF_BITMAP` is: the system converts between them.
const DIBS: [u32; 2] = [formats::CF_DIBV5, formats::CF_DIB];
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 mut files = vec![];
{
let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;
// None copied when the clipboard holds no file list.
let _ = raw::get_file_list_path(&mut files);
}
// The files are read with the clipboard closed again, not keeping other apps waiting.
copied_file(files, mimes).or_else(|| {
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 {
raw::is_format_avail(formats::CF_HDROP)
|| mimes.iter().any(|m| match *m {
TEXT => raw::is_format_avail(formats::CF_UNICODETEXT),
BITMAP => DIBS.into_iter().chain(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 => {
return DIBS.into_iter().find_map(|id| {
data.clear();
raw::get_vec(id, &mut data).ok()?;
png_flavour(&dib_image(&data)?)
});
}
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::*;
/// 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()?;
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
return Some(f);
}
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 => {
#[cfg(target_os = "macos")]
if let Some(f) = super::mac::bitmap() {
return Some(f);
}
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())?;
png_flavour(&rgba)
}
_ => None,
})
}
/// Text, and on macOS a bitmap (by the pasteboard's types). Linux has no cheap way to ask
/// for a bitmap (reading one costs as much as pasting it): the paste keys take it, the
/// menu item waits for text.
fn has(&mut self, mimes: &[&'static str]) -> bool {
#[cfg(target_os = "macos")]
if mimes.contains(&BITMAP) && super::mac::has_bitmap() {
return true;
}
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
}
}
}
/// The macOS pasteboard's bitmaps as they are (arboard reads TIFF only, and decodes it).
#[cfg(target_os = "macos")]
mod mac {
use objc2::rc::autoreleasepool;
use objc2_app_kit::NSPasteboard;
use objc2_foundation::{NSArray, NSString};
use super::*;
/// The bitmap types Paste reads, best first (the values of `NSPasteboardTypePNG` and
/// `NSPasteboardTypeTIFF`), with their MIME types.
const TYPES: [(&str, &str); 2] = [("public.png", PNG), ("public.tiff", "image/tiff")];
/// Does the pasteboard hold a bitmap? Asks for its types only.
pub fn has_bitmap() -> bool {
autoreleasepool(|_| {
let types = TYPES.map(|(t, _)| NSString::from_str(t));
let types: Vec<&NSString> = types.iter().map(|t| &**t).collect();
NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
})
}
/// The pasteboard's bitmap, PNG before TIFF.
pub fn bitmap() -> Option<Flavour> {
autoreleasepool(|_| {
let pasteboard = NSPasteboard::generalPasteboard();
TYPES.into_iter().find_map(|(t, mime)| {
let data = pasteboard.dataForType(&NSString::from_str(t))?.to_vec();
(!data.is_empty()).then_some(Flavour { mime, data })
})
})
}
}
#[cfg(test)]
mod tests {
use vectorcraft_engine::cmd::clipboard::{PASTE_ORDER, SVG};
use super::*;
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vectorcraft-clipboard-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn png(w: u32, h: u32) -> Vec<u8> {
let mut out = vec![];
image::RgbaImage::new(w, h).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
#[test]
fn the_first_copied_file_that_is_art_pastes() {
let d = dir("art");
let (notes, red, art) = (d.join("notes.txt"), d.join("red image.png"), d.join("art.svg"));
std::fs::write(&notes, "not art").unwrap();
std::fs::write(&red, png(3, 2)).unwrap();
std::fs::write(&art, "<svg xmlns=\"http://www.w3.org/2000/svg\"/>").unwrap();
// Missing files, folders and text are skipped.
let f = copied_file(vec![d.join("gone.png"), d.clone(), notes.clone(), red.clone(), art.clone()], &PASTE_ORDER).unwrap();
assert_eq!((f.mime, f.data), (PNG, png(3, 2)));
assert_eq!(copied_file(vec![art.clone(), red.clone()], &PASTE_ORDER).map(|f| f.mime), Some(SVG));
// Only what Paste asks for.
assert_eq!(copied_file(vec![art, red.clone()], &[BITMAP]).map(|f| f.mime), Some(PNG));
assert!(copied_file(vec![red], &[TEXT]).is_none());
assert!(copied_file(vec![notes], &PASTE_ORDER).is_none());
assert!(copied_file(vec![], &PASTE_ORDER).is_none());
}
#[test]
fn a_crlf_left_by_the_uri_list_is_ignored() {
let red = dir("crlf").join("red.png");
std::fs::write(&red, png(2, 2)).unwrap();
let with_cr = PathBuf::from(format!("{}\r", red.display()));
assert_eq!(copied_file(vec![with_cr], &PASTE_ORDER).map(|f| f.mime), Some(PNG));
}
/// A DIB of `w`×`h` pixels (top row first when `h` is negative): a `header`-byte header (40,
/// or 124 for a BITMAPV5HEADER with BGRA masks), `extra` (the masks after a 40-byte header, a
/// colour table), then `rows`, each padded to 4 bytes.
fn dib(header: u32, w: i32, h: i32, bits: u16, compression: u32, extra: &[u8], rows: &[u8]) -> Vec<u8> {
let mut d = vec![];
d.extend(header.to_le_bytes());
d.extend(w.to_le_bytes());
d.extend(h.to_le_bytes());
d.extend(1u16.to_le_bytes());
d.extend(bits.to_le_bytes());
d.extend(compression.to_le_bytes());
d.extend((rows.len() as u32).to_le_bytes());
// 96 ppi, every colour of the table used.
d.extend([3780u32, 3780, 0, 0].iter().flat_map(|v| v.to_le_bytes()));
if header == 124 {
d.extend([0x00FF_0000u32, 0xFF00, 0xFF, 0xFF00_0000, 0x7352_4742].iter().flat_map(|v| v.to_le_bytes()));
d.resize(124, 0);
}
d.extend_from_slice(extra);
d.extend_from_slice(rows);
d
}
/// 32-bit BGRA rows of `px` (top row first).
fn bgra(px: &[[u8; 4]]) -> Vec<u8> {
px.iter().flat_map(|[r, g, b, a]| [*b, *g, *r, *a]).collect()
}
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
fn pixels(img: &image::RgbaImage) -> Vec<[u8; 4]> {
img.pixels().map(|p| p.0).collect()
}
#[test]
fn a_screenshot_dib_pastes_opaque_and_upright() {
// CF_DIB as screenshots copy it: 32-bit BI_RGB, bottom row first, the fourth byte zero.
let rows = bgra(&[[0, 0, 255, 0], [0, 0, 255, 0], [255, 0, 0, 0], [255, 0, 0, 0]]);
let img = dib_image(&dib(40, 2, 2, 32, 0, &[], &rows)).unwrap();
assert_eq!(pixels(&img), [RED, RED, BLUE, BLUE]);
// The same bitmap with an alpha mask (CF_DIBV5), alpha still zero throughout: opaque too.
let img = dib_image(&dib(124, 2, -2, 32, 3, &[], &bgra(&[[255, 0, 0, 0]; 4]))).unwrap();
assert_eq!(pixels(&img), [RED; 4]);
}
#[test]
fn a_dibv5_keeps_its_alpha_top_down() {
// A negative height: top row first.
let rows = bgra(&[[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 0], [0, 0, 255, 255]]);
let img = dib_image(&dib(124, 2, -2, 32, 3, &[], &rows)).unwrap();
assert_eq!(pixels(&img), [[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 0], BLUE]);
}
#[test]
fn masks_and_colour_tables_come_before_the_pixels() {
// BI_BITFIELDS after a 40-byte header: three masks between it and the pixels.
let masks: Vec<u8> = [0x00FF_0000u32, 0xFF00, 0xFF].iter().flat_map(|v| v.to_le_bytes()).collect();
let img = dib_image(&dib(40, 1, 1, 32, 3, &masks, &bgra(&[[0, 0, 255, 0]]))).unwrap();
assert_eq!(pixels(&img), [BLUE]);
// 8-bit, two colours used, rows padded to 4 bytes, bottom row first.
let mut table = bgra(&[RED, BLUE]);
table[3] = 0;
let mut d = dib(40, 3, 2, 8, 0, &table, &[1, 1, 1, 0, 0, 1, 0, 0]);
d[32..36].copy_from_slice(&2u32.to_le_bytes());
assert_eq!(pixels(&dib_image(&d).unwrap()), [RED, BLUE, RED, BLUE, BLUE, BLUE]);
// 24-bit, rows padded.
let img = dib_image(&dib(40, 1, 2, 24, 0, &[], &[255, 0, 0, 0, 0, 0, 255, 0])).unwrap();
assert_eq!(pixels(&img), [RED, BLUE]);
}
#[test]
fn untrusted_dibs_are_refused_not_trusted() {
let one = bgra(&[RED]);
// Too large a side (the pixels aren't even there), cut short, a header or a colour count
// out of range, no pixels.
assert!(dib_image(&dib(40, MAX_SIDE as i32 + 1, 1, 32, 0, &[], &one)).is_none());
assert!(dib_image(&dib(40, 1, -(MAX_SIDE as i32) - 1, 32, 0, &[], &one)).is_none());
assert!(dib_image(&dib(40, 4096, 4096, 32, 0, &[], &one)).is_none());
assert!(dib_image(&[40, 0, 0]).is_none());
assert!(dib_image(&[]).is_none());
let mut d = dib(40, 1, 1, 32, 0, &[], &one);
d[0..4].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(dib_image(&d).is_none());
let mut d = dib(40, 1, 1, 8, 0, &[], &[0; 4]);
d[32..36].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(dib_file(&d).is_none());
assert!(dib_image(&dib(40, 1, 1, 32, 0, &[], &[])).is_none());
assert!(dib_image(&dib(40, 0, 0, 32, 0, &[], &[])).is_none());
}
}
+251 -32
View File
@@ -1,14 +1,28 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`vectorcraft mcp --connect`) wraps.
//!
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
//! never reaches the UI thread. See `docs/control-protocol.md`.
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use serde_json::{Value, json};
use vectorcraft_ui_egui::ControlRequest;
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
/// anything longer is refused and the connection closed.
pub const MAX_LINE: usize = 4 * 1024 * 1024;
/// Connections served at once; further ones get an error line and are closed.
pub const MAX_CONNECTIONS: usize = 16;
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
@@ -19,44 +33,249 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
};
eprintln!("vectorcraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
}
});
std::thread::spawn(move || accept_loop(listener, tx, ctx));
rx
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
// Best effort: the connection is dropped either way, and a peer that can't read the reason loses nothing.
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
let tx = tx.clone();
let ctx = ctx.clone();
let open = Arc::clone(&open);
std::thread::spawn(move || {
serve(stream, &tx, &ctx);
open.fetch_sub(1, Ordering::SeqCst);
});
}
}
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
tx.send(req).ok()?;
ctx.request_repaint();
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
});
}
/// One parsed line.
enum Line {
Request {
id: Value,
method: String,
params: Value,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Line::Blank;
}
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
Line::Request {
id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
}
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
/// returns the reply object (the request's `id` is added here).
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => return,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
let Some(mut r) = handle(method, params) else { return };
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
return;
}
continue;
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
},
};
if writeln!(out, "{reply}").is_err() {
break;
// Best effort: the connection closes either way, and a peer that can't read the reason loses nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
return;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
serve_lines(input, &mut out, |m, _p| {
calls.push(m);
Some(json!({"ok": true, "result": null}))
});
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
(replies, calls)
}
#[test]
fn requests_are_answered_in_order_with_ids() {
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
assert_eq!(calls, ["a", "b", "c"]);
assert_eq!(r.len(), 3);
assert_eq!(r[0]["id"], 1);
assert_eq!(r[1]["id"], 2);
assert_eq!(r[2]["id"], Value::Null);
}
#[test]
fn http_request_closes_before_the_body_runs() {
// A browser's cross-origin `fetch` POST: the request line is rejected and the
// connection closed, so the JSON body is never executed.
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n";
let (r, calls) = run(http);
assert!(calls.is_empty(), "nothing may run: {calls:?}");
assert_eq!(r.len(), 1);
assert_eq!(r[0]["ok"], false);
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
}
#[test]
fn non_request_json_closes_the_connection() {
for junk in [
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
b"{\"id\":1}\n{\"method\":\"a\"}\n",
b"{\"method\":5}\n{\"method\":\"a\"}\n",
b"\xff\xfe\n{\"method\":\"a\"}\n",
] {
let (r, calls) = run(junk);
assert!(calls.is_empty(), "{calls:?}");
assert_eq!(r.len(), 1);
assert_eq!(r[0]["ok"], false);
}
}
#[test]
fn a_request_before_junk_still_runs() {
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
assert_eq!(calls, ["a"]);
assert_eq!(r.len(), 2);
assert_eq!(r[1]["ok"], false);
}
#[test]
fn overlong_line_is_refused() {
// One byte over the limit, then a valid request: neither runs.
let mut input = vec![b'x'; MAX_LINE + 1];
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
let (r, calls) = run(&input);
assert!(calls.is_empty(), "{calls:?}");
assert_eq!(r.len(), 1);
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
}
#[test]
fn line_at_the_limit_is_accepted() {
let mut buf = Vec::new();
let line = vec![b'a'; 10];
let mut input = line.clone();
input.push(b'\n');
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
assert_eq!(buf, line);
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
}
#[test]
fn tcp_connection_is_closed_after_junk() {
// End to end over a real loopback socket (the transport the app uses).
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let read = s.try_clone().unwrap();
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
let mut text = String::new();
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
server.join().unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].contains("\"id\":7"));
assert!(lines[1].contains("closing the connection"));
}
#[test]
fn extra_connections_are_refused() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, _rx) = channel::<ControlRequest>();
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut line = String::new();
BufReader::new(&mut extra).read_line(&mut line).unwrap();
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
}
+582
View File
@@ -0,0 +1,582 @@
//! The desktop app's logger: `log::` records go to standard error and to a log file.
//!
//! Without a logger every `log::warn!`/`log::error!` in the workspace (the graphics adapter, a
//! lost graphics device, a panic the engine's guard recovered from) vanished. A launch from a desktop menu
//! has no terminal, so the file is what a bug report can attach: `<settings dir>/logs/vectorcraft.log`,
//! next to the preferences (`prefs_path` in `main.rs`). Each start moves the previous log to
//! `vectorcraft.1.log` (and that one to `.2`), so the log of a run that crashed survives the next
//! launch. Runs without preferences (`VECTORCRAFT_NO_PREFS`) log to standard error only.
//!
//! Levels: `info` for VectorCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
//! `warn,vectorcraft_render=trace`, `wgpu_core=info`. A directive ending in `*` matches every
//! target that starts with it (`vectorcraft*=debug`).
//!
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
//!
//! This is the desktop app's logger only. `vectorcraft-cli mcp` installs its own
//! (`vectorcraft_mcp::logging`, MCP `notifications/message`); the two never share a process.
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::SystemTime;
use log::LevelFilter;
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "vectorcraft.log";
/// How many previous logs are kept (`vectorcraft.1.log` … `vectorcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The built-in filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,vectorcraft*=info";
/// Per-target level filter parsed from env_logger-style directives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filter {
default: LevelFilter,
directives: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
/// directive names log errors only.
pub fn parse(spec: &str) -> Filter {
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('=') {
Some((name, level)) => {
let name = name.trim();
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
f.directives.push((name.to_owned(), level));
}
}
None => match parse_level(part) {
Some(level) => f.default = level,
// A bare target name: everything from it (env_logger does the same).
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
},
}
}
f
}
/// The level that applies to `target` (the most specific matching directive wins).
pub fn level_for(&self, target: &str) -> LevelFilter {
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
}
/// The most verbose level any target can reach (for `log::set_max_level`).
pub fn max(&self) -> LevelFilter {
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
}
}
fn parse_level(s: &str) -> Option<LevelFilter> {
s.trim().parse().ok()
}
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
/// `name*` matches every target starting with `name`.
fn matches(name: &str, target: &str) -> bool {
match name.strip_suffix('*') {
Some(prefix) => target.starts_with(prefix),
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
/// value can overflow it.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 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 d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("vectorcraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, line: &str) {
if self.file.is_none() {
if self.capped {
// No file is coming ([`Sink::no_file`]): standard error had the line.
} else if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
self.written = 0;
self.capped = false;
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
/// There will be no log file: forget the lines kept for it and keep no more.
pub fn no_file(&mut self) {
self.pending = Vec::new();
self.dropped = 0;
self.capped = true;
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error
/// standard error stays the only sink.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
// A thread that panicked while logging leaves the sink usable: take it back.
let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner);
match rotate(dir) {
Ok((path, file)) => {
sink.attach(file);
Ok(path)
}
Err(e) => {
sink.no_file();
Err(format!("{}: {e}", dir.display()))
}
}
}
/// No log file for this run (runs without preferences): log to standard error only.
pub fn no_file(&self) {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
let thread = std::thread::current();
let line =
format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
let _ = std::io::stderr().write_all(line.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("vectorcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
#[test]
fn the_default_filter_shows_vectorcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("vectorcraft"), LevelFilter::Info);
assert_eq!(f.level_for("vectorcraft::window"), LevelFilter::Info);
assert_eq!(f.level_for("vectorcraft_engine::guard"), LevelFilter::Info);
assert_eq!(f.level_for("vectorcraft_ui_egui::io"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,vectorcraft_render=trace,vectorcraft_render::tiles=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("vectorcraft_render::canvas"), LevelFilter::Trace);
assert_eq!(f.level_for("vectorcraft_render::tiles"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("vectorcraft*=debug").level_for("vectorcraft_text::shape"), LevelFilter::Debug);
assert_eq!(Filter::parse("vectorcraft*=debug").level_for("eframe"), LevelFilter::Error);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive and surrounding blanks are ignored.
assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("vectorcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "x".repeat(100_000);
let many = "a=info,".repeat(10_000);
for spec in [
"",
",,,",
"=",
"==",
"=debug",
"vectorcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=",
"=*",
"a=b=c",
"warn,,vectorcraft=DEBUG",
"\0",
"é::ü=trace",
&long,
&many,
] {
let f = Filter::parse(spec);
for target in ["", "vectorcraft", "🦀", "a::b", "*"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,vectorcraft=DEBUG").level_for("vectorcraft"), LevelFilter::Debug);
// An unknown level is skipped: that target keeps the default.
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("vectorcraft=").level_for("vectorcraft"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "vectorcraft", "rendering with Mesa (Vulkan, IntegratedGpu)"),
"2026-10-08T07:59:17.728Z INFO [main] vectorcraft: rendering with Mesa (Vulkan, IntegratedGpu)\n"
);
// A multi-line message keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("vectorcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("vectorcraft.2.log")), "run 2");
assert!(!dir.join("vectorcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_a_full_pending_buffer_drops_nothing() {
let dir = temp_dir("full");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept_in_memory() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let record = |level, target: &'static str, msg: &'static str| {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
};
record(log::Level::Info, "vectorcraft", "before the settings directory");
record(log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(log::Level::Error, "vectorcraft_engine::guard", "recovered from a panic: boom");
record(log::Level::Debug, "vectorcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("vectorcraft: before the settings directory"), "{text}");
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("vectorcraft_engine::guard: recovered from a panic: boom"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("vectorcraft_ui_egui").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// Attaching again rotates: the first log becomes `.1`.
logger.attach_dir(&dir).expect("attach again");
assert_eq!(read(&dir.join("vectorcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
use log::Log;
let dir = temp_dir("threads");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let path = logger.attach_dir(&dir).expect("attach");
let workers: Vec<_> = (0..8)
.map(|t| {
let l = std::sync::Arc::clone(&logger);
std::thread::spawn(move || {
for i in 0..100 {
l.log(
&log::Record::builder().level(log::Level::Info).target("vectorcraft").args(format_args!("thread {t} record {i}")).build(),
);
}
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
let text = read(&path);
assert_eq!(text.lines().count(), 800);
assert!(text.lines().all(|l| l.contains(" vectorcraft: thread ")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
use log::Log;
let dir = temp_dir("unusable");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
assert!(logger.attach_dir(&dir).is_err());
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
// Nothing waits in memory for a file that won't come.
assert!(logger.sink.lock().expect("sink").pending.is_empty());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_sink_still_logs() {
use log::Log;
let dir = temp_dir("poison");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let l = std::sync::Arc::clone(&logger);
let _ = std::thread::spawn(move || {
let _guard = l.sink.lock().expect("lock");
panic!("poison the sink");
})
.join();
assert!(logger.sink.is_poisoned());
let path = logger.attach_dir(&dir).expect("attach");
logger.log(&log::Record::builder().level(log::Level::Warn).target("vectorcraft").args(format_args!("after the poison")).build());
assert!(read(&path).contains("vectorcraft: after the poison"));
let _ = std::fs::remove_dir_all(&dir);
}
}
+365 -38
View File
@@ -5,40 +5,112 @@
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `vectorcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))]
compile_error!("windows7 requires --no-default-features (wgpu and accessibility must be disabled)");
#[cfg(all(windows, feature = "windows7", not(target_vendor = "win7")))]
compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinary Windows target still imports newer APIs");
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
compile_error!("the win7 target requires --no-default-features --features windows7");
mod clipboard;
mod control_server;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
#[cfg(target_os = "macos")]
mod open_documents;
mod printing;
mod window;
use vectorcraft_engine::Session;
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp};
struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
struct App {
app: VectorcraftApp,
/// Reported by wgpu when the window's graphics device is lost (a driver reset).
graphics_loss: GraphicsLoss,
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
graphics_lost: bool,
#[cfg(target_os = "macos")]
menu: Option<native_menu::NativeMenu>,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Some(why) = self.graphics_loss.take() {
// eframe can't give a window a new device: the user saves and starts again.
self.graphics_lost = self.app.graphics_lost(&why);
self.app.status(if self.graphics_lost {
"The graphics device was lost: unsaved changes are kept for Data Recovery. Save your documents and restart VectorCraft"
} else {
"The graphics device was lost: save your documents and restart VectorCraft"
});
}
if self.graphics_lost && ctx.input(|i| i.viewport().close_requested()) {
// The window can't show the Save Changes question: it closes, and the next launch
// offers the changes back.
vectorcraft_ui_egui::background::wait_all(&mut self.app);
return;
}
#[cfg(target_os = "macos")]
{
if self.1.is_none() && std::env::var_os("VECTORCRAFT_NO_NATIVE_MENU").is_none() {
self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
if self.menu.is_none() && std::env::var_os("VECTORCRAFT_NO_NATIVE_MENU").is_none() {
self.menu = Some(native_menu::NativeMenu::install(&mut self.app));
}
if let Some(m) = &mut self.1 {
m.poll(&mut self.0);
if let Some(m) = &mut self.menu {
m.poll(&mut self.app, ctx);
}
open_files(&mut self.app, open_documents::take());
}
self.0.logic(ctx);
if self.0.ui.status == "quit" {
self.app.logic(ctx);
window::track(ctx, &mut self.app.ui.window);
if self.app.ui.status == "quit" {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.app.ui(ui);
#[cfg(target_os = "macos")]
if self.app.take_ime_discard() {
discard_marked_text();
}
}
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
save_prefs(&self.0);
save_prefs(&self.app);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
save_prefs(&self.app);
}
}
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
/// can't be opened is reported in the status bar and on stderr; the others still open.
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
for f in files {
if let Err(e) = vectorcraft_ui_egui::io::open_path(app, &f) {
eprintln!("vectorcraft: {f}: {e}");
app.status(format!("Couldn't open {}: {e}", fileio::file_name(&f)));
}
}
}
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
/// typed). winit's IME toggle only clears its own copy, so the IME would type it again.
#[cfg(target_os = "macos")]
fn discard_marked_text() {
if let Some(mtm) = objc2::MainThreadMarker::new()
&& let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm)
{
ic.discardMarkedText();
}
}
@@ -68,21 +140,37 @@ fn prefs_path_for(name: &str, lower: &str) -> Option<std::path::PathBuf> {
base.map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut VectorcraftApp) {
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
/// Where the log files live: `logs` in the preferences folder (see `logging`).
fn log_dir() -> Option<std::path::PathBuf> {
Some(prefs_path()?.parent()?.join("logs"))
}
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("VECTORCRAFT_NO_PREFS").is_none()
}
/// The saved UI preferences, read before the window opens (they hold its size and position).
fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
if !prefs_enabled() {
return None;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
serde_json::from_slice(&bytes).ok()
}
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
if !prefs_enabled() {
return;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()));
if let Some(bytes) = bytes
&& let Ok(ui) = serde_json::from_slice::<vectorcraft_ui_egui::UiState>(&bytes)
{
if let Some(ui) = saved {
app.ui = ui.sanitized();
}
vectorcraft_ui_egui::prefs_dialog::restore(app);
}
fn save_prefs(app: &VectorcraftApp) {
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
if !prefs_enabled() {
return;
}
if let Some(p) = prefs_path() {
@@ -90,32 +178,114 @@ 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()))),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..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,7 +297,37 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// The power preference wgpu picks the window's graphics adapter with (#306): the
/// `WGPU_POWER_PREF` environment variable (`low`, `high`, `none`) when set, otherwise Preferences ›
/// Performance › Graphics Processor (`gpuPreference`). Power saving unless the preference asks for
/// high performance: the canvas is rasterized on the CPU and the GPU only composites it, which the
/// integrated GPU of a hybrid-graphics laptop does easily, while presenting frames rendered on the
/// discrete GPU through the integrated one made the window flicker on some laptops. With a single
/// GPU both preferences pick it.
#[cfg(feature = "wgpu")]
fn power_preference(pref: Option<&str>, env: Option<eframe::wgpu::PowerPreference>) -> eframe::wgpu::PowerPreference {
use eframe::wgpu::PowerPreference;
match (env, pref) {
(Some(p), _) => p,
(None, Some("highPerformance")) => PowerPreference::HighPerformance,
(None, _) => PowerPreference::LowPower,
}
}
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
#[cfg(feature = "wgpu")]
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
}
/// 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 {
// First, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut args = std::env::args().skip(1);
@@ -141,40 +341,167 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
// The log file lives in the settings directory, next to the preferences; opened after the
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
// first. Runs without preferences (agents' test runs) log to standard error only, so they
// don't rotate away the user's own logs.
if let Some(logger) = logger {
match log_dir().filter(|_| prefs_enabled()) {
Some(dir) => match logger.attach_dir(&dir) {
Ok(path) => log::info!("VectorCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
},
None => logger.no_file(),
}
}
let saved = read_prefs();
let saved_window = saved.as_ref().and_then(|ui| ui.window);
#[cfg(feature = "wgpu")]
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
#[cfg(feature = "wgpu")]
let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_icon(app_icon())
.with_app_id("ai.storyteller.vectorcraft"),
#[cfg(feature = "windows7")]
renderer: eframe::Renderer::Glow,
..Default::default()
};
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
// One frame queued, not two: the canvas is rasterized on the CPU and the GPU only
// composites it, so the window answers the pointer a frame sooner (#444).
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.power_preference = power;
}
options
};
// Files opened from Finder and the Dock arrive as events, not arguments.
#[cfg(target_os = "macos")]
open_documents::install();
eframe::run_native(
"VectorCraft",
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services());
load_prefs(&mut app);
load_prefs(&mut app, saved);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
app.ui.window = Some(window::restore(w, saved_window));
}
// 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);
// Data Recovery copies live next to the preferences too (none for runs without
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
app.session.recovery.set_default_folder(recovery);
}
let graphics_loss = GraphicsLoss::default();
#[cfg(feature = "wgpu")]
if let Some(rs) = &cc.wgpu_render_state {
let summary = adapter_summary(&rs.adapter.get_info());
log::info!("rendering with {summary} (power preference {power:?})");
app.graphics_adapter = Some(summary);
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
#[cfg(feature = "windows7")]
{
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = CUSTOM_TITLEBAR;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
for f in files {
if let Err(e) = vectorcraft_ui_egui::io::open_path(&mut app, &f) {
eprintln!("vectorcraft: {f}: {e}");
}
}
Ok(Box::new(App(
#[cfg(target_os = "macos")]
open_documents::set_ui(&cc.egui_ctx);
open_files(&mut app, files);
Ok(Box::new(App {
app,
graphics_loss,
graphics_lost: false,
#[cfg(target_os = "macos")]
None,
)))
menu: None,
}))
}),
)
}
#[cfg(all(test, feature = "wgpu"))]
mod tests {
use super::*;
use eframe::wgpu::PowerPreference;
#[test]
fn power_saving_unless_the_preference_or_the_environment_says_otherwise() {
assert_eq!(power_preference(None, None), PowerPreference::LowPower);
assert_eq!(power_preference(Some("powerSaving"), None), PowerPreference::LowPower);
assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance);
// A value this version doesn't know (a newer or damaged preference file) is the default.
assert_eq!(power_preference(Some("turbo"), None), PowerPreference::LowPower);
assert_eq!(power_preference(Some(""), None), PowerPreference::LowPower);
// WGPU_POWER_PREF wins over the preference, either way.
assert_eq!(power_preference(Some("powerSaving"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance);
assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower);
assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None);
}
/// The file extensions the macOS bundle declares: its document types and its own exported type.
fn plist_extensions(plist: &str) -> Vec<&str> {
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
.iter()
.flat_map(|key| plist.split(key).skip(1))
.filter_map(|rest| rest.split("</array>").next())
.flat_map(|array| array.split("<string>").skip(1))
.filter_map(|s| s.split("</string>").next())
.collect()
}
/// Finder offers the app for every file File › Open reads (#295, #354), takes over no other
/// app's files, and hands them to the app rather than to AppKit's document machinery.
#[test]
fn the_macos_bundle_opens_every_readable_format() {
let plist = include_str!("../../../packaging/macos/Info.plist.in");
let declared = plist_extensions(plist);
for e in fileio::OPEN_EXTS {
assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}");
}
for e in &declared {
assert!(fileio::OPEN_EXTS.contains(e), "Info.plist.in declares .{e}, which the app doesn't open");
}
assert!(!plist.contains("<key>NSDocumentClass</key>"), "not an NSDocument app: AppKit would refuse the files");
let types = plist.matches("<key>CFBundleTypeName</key>").count();
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
}
/// The preference the engine saves is the one the app reads back before the window opens.
#[test]
fn the_saved_engine_preference_is_found() {
let mut prefs = vectorcraft_engine::Prefs::default();
let saved = prefs.to_json();
assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("powerSaving"));
prefs.gpu_preference = "highPerformance".into();
let saved = prefs.to_json();
let pref = saved.get("gpuPreference").and_then(serde_json::Value::as_str);
assert_eq!(power_preference(pref, None), PowerPreference::HighPerformance);
}
}
+39 -15
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;
@@ -9,6 +10,7 @@ use muda::accelerator::Accelerator;
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::Value;
use vectorcraft_ui_egui::VectorcraftApp;
use vectorcraft_ui_egui::i18n::{self, Lang, t};
use vectorcraft_ui_egui::menus::{self, Item};
enum Handle {
@@ -22,10 +24,16 @@ 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,
/// Plug-in registry revision the Plug-ins submenus were built for.
plugins: u64,
/// The language the labels were built in (a change rebuilds the menu).
language: Lang,
}
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
/// don't steal keys from text fields).
/// don't steal keys from text fields; a focused field still gets Cmd+A/X/C/V, see [`NativeMenu::poll`]).
fn accel(sc: &str) -> Option<Accelerator> {
if !(sc.contains("Cmd") || sc.contains("Ctrl") || sc.contains("Alt") || sc.starts_with('F')) {
return None;
@@ -42,7 +50,7 @@ impl NativeMenu {
let mut items = HashMap::new();
let mut counter = 0usize;
for (title, entries) in menus::menu_tree() {
let sub = Submenu::new(title, true);
let sub = Submenu::new(t(title), true);
build(app, &sub, &entries, &mut items, &mut counter);
let _ = menu.append(&sub);
}
@@ -53,15 +61,24 @@ 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),
plugins: menus::plugin_revision(),
language: i18n::current(),
}
}
/// Dispatch clicked items and refresh state.
pub fn poll(&mut self, app: &mut VectorcraftApp) {
/// Dispatch chosen items (a focused text field takes Select All and the clipboard) and refresh
/// state.
pub fn poll(&mut self, app: &mut VectorcraftApp, ctx: &egui::Context) {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
if let Some((cmd, params, _, _)) = self.items.get(ev.id.as_ref()) {
let p = if params.is_null() { serde_json::json!({}) } else { params.clone() };
menus::invoke(app, cmd, p);
menus::invoke_from_system_menu(app, ctx, cmd, p);
}
}
let now = vectorcraft_ui_egui::now_ms();
@@ -69,8 +86,13 @@ 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, recent files, plug-ins or the UI language
// changed: rebuild so accelerators, lists and labels are current.
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|| menus::listed_slots(app) != self.slots
|| menus::plugin_revision() != self.plugins
|| i18n::current() != self.language
{
*self = NativeMenu::install(app);
return;
}
@@ -102,7 +124,7 @@ impl NativeMenu {
"view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters"
)
{
i.set_text(menus::dynamic_label(app, cmd, ""));
i.set_text(menus::display_label(app, cmd, ""));
}
}
Handle::Check(c) => {
@@ -127,26 +149,28 @@ fn build(
let _ = parent.append(&PredefinedMenuItem::separator());
}
Item::Header(h) => {
let _ = parent.append(&MenuItem::new(*h, false, None));
let _ = parent.append(&MenuItem::new(t(h), false, None));
}
Item::Todo(label, sc) => {
let _ = parent.append(&MenuItem::new(*label, false, accel(sc)));
let _ = parent.append(&MenuItem::new(t(label), false, accel(sc)));
}
Item::Sub(label, children) => {
let sub = Submenu::new(*label, true);
let sub = Submenu::new(t(label), true);
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}");
let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None };
let handle = if menus::checked(app, cmd, params).is_some() {
let c = CheckMenuItem::with_id(id.clone(), *label, true, false, sc);
let c = CheckMenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, false, sc);
let _ = parent.append(&c);
Handle::Check(c)
} else {
let i = MenuItem::with_id(id.clone(), *label, true, sc);
let i = MenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, sc);
let _ = parent.append(&i);
Handle::Plain(i)
};
+105
View File
@@ -0,0 +1,105 @@
//! macOS: files opened from Finder (double-click, Open With, a drop on the Dock icon) or with
//! `open -a VectorCraft file`.
//!
//! macOS doesn't pass these on the command line: AppKit hands them to the application delegate's
//! `application:openURLs:`, and when the delegate has no such method it asks `NSDocumentController`,
//! which fails with "VectorCraft cannot open files in the … format" because VectorCraft isn't an
//! `NSDocument` app. winit 0.30's delegate doesn't implement the method, so [`install`] adds it to
//! the delegate's class when the app is about to finish launching: after winit set the delegate and
//! before AppKit delivers the files the app was launched with. The handler queues the paths and
//! wakes the UI, which opens them with [`take`] like files named on the command line.
//!
//! Adding a method to another crate's Objective-C class has no safe API, hence the scoped
//! `unsafe_code` allowance (winit overrides `sendEvent:` on `NSApplication` the same way).
#![allow(unsafe_code)]
use std::ptr::NonNull;
use std::sync::{Mutex, OnceLock, PoisonError};
use block2::RcBlock;
use objc2::MainThreadMarker;
use objc2::runtime::{AnyObject, Imp, Sel};
use objc2::sel;
use objc2_app_kit::{NSApplication, NSApplicationWillFinishLaunchingNotification};
use objc2_foundation::{NSArray, NSNotification, NSNotificationCenter, NSURL};
/// Paths that arrived and haven't been opened yet.
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
static UI: OnceLock<egui::Context> = OnceLock::new();
/// Handle open-document events from now on. Call before the event loop runs.
pub fn install() {
let block = RcBlock::new(|_: NonNull<NSNotification>| add_open_urls_method());
// SAFETY: the name is AppKit's notification constant; no object or queue filter, so the block
// runs on the posting thread (AppKit posts it on the main thread). The notification center
// keeps the registration (and the block) for the life of the app.
unsafe {
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
Some(NSApplicationWillFinishLaunchingNotification),
None,
None,
&block,
);
}
}
/// Wake `ctx` when files arrive while the app runs.
pub fn set_ui(ctx: &egui::Context) {
// Set once; a second call would hand over the same context.
let _ = UI.set(ctx.clone());
}
/// The paths that arrived since the last call.
pub fn take() -> Vec<String> {
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
}
/// `application:openURLs:` on the application delegate's class.
fn add_open_urls_method() {
let Some(mtm) = MainThreadMarker::new() else { return };
let Some(delegate) = NSApplication::sharedApplication(mtm).delegate() else {
log::warn!("no application delegate; files opened from Finder won't open");
return;
};
let class = AsRef::<AnyObject>::as_ref(&*delegate).class();
let handler: extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>) = open_urls;
// SAFETY: `Imp` is the untyped form of a method implementation; the Objective-C runtime calls
// it with the arguments the type encoding declares, which match `open_urls`: no return value
// (`v`), the receiver (`@`), the selector (`:`), the application (`@`) and the URL array (`@`).
let added = unsafe {
let imp = std::mem::transmute::<extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>), Imp>(handler);
objc2::ffi::class_addMethod(std::ptr::from_ref(class).cast_mut(), sel!(application:openURLs:), imp, c"v@:@@".as_ptr())
};
if !added.as_bool() {
log::warn!("the application delegate already handles open-document events");
}
}
/// Queue the files among `urls` and wake the UI. Never panics: it runs inside AppKit.
extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray<NSURL>) {
let paths = urls.iter().filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths);
if let Some(ctx) = UI.get() {
ctx.request_repaint();
}
}
#[cfg(test)]
mod tests {
use objc2::runtime::NSObject;
use objc2_foundation::NSString;
use super::*;
/// Files queue until the UI takes them; URLs that aren't files are left out.
#[test]
fn opened_files_wait_for_the_ui() {
let web = NSURL::URLWithString(&NSString::from_str("https://example.com/a.svg")).unwrap();
let urls = NSArray::from_retained_slice(&[NSURL::from_file_path("/tmp/a b.pdf").unwrap(), web, NSURL::from_file_path("/tmp/c.ai").unwrap()]);
let any = NSObject::new();
open_urls(&any, sel!(application:openURLs:), &any, &urls);
assert_eq!(take(), ["/tmp/a b.pdf", "/tmp/c.ai"]);
assert!(take().is_empty());
}
}
+222
View File
@@ -0,0 +1,222 @@
//! File → Print on the desktop ([`SystemPrint`]): the system's printers and print queue. macOS and
//! Linux go through CUPS (`lpstat` lists the printers, `lp` queues a temporary copy of the job's
//! PDF); Windows asks PowerShell for the printers and prints through the shell's print verb of
//! the PDF viewer.
use std::path::PathBuf;
use std::process::Command;
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
/// The system's printing.
pub struct SystemPrint;
impl PrintService for SystemPrint {
fn printers(&mut self) -> Vec<Printer> {
system::printers()
}
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
let file = job_file(job.pdf)?;
let r = system::print(job, &file);
let to = job.printer.unwrap_or("the default printer");
r.map(|note| format!("Sent “{}” to {to}{note}", job.title))
}
fn has_setup(&self) -> bool {
true
}
fn setup(&mut self, printer: Option<&str>) -> Result<(), String> {
system::setup(printer)
}
}
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
/// than an hour old (the print verb reads them after Print returns) are removed first.
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join("vectorcraft-print");
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
if old {
// Another job may still be reading it: leave it for next time.
let _ = std::fs::remove_file(e.path());
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
std::fs::write(&path, pdf).map_err(|e| format!("can't write {}: {e}", path.display()))?;
Ok(path)
}
/// Run `c` → its output, or its error message.
fn output(c: &mut Command) -> Result<String, String> {
let program = c.get_program().to_string_lossy().to_string();
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
}
/// `lpstat -e` / `lpstat -a` → the printer names (the first word of each line).
#[cfg(any(test, not(windows)))]
fn lpstat_names(text: &str) -> Vec<String> {
let mut names: Vec<String> = vec![];
for name in text.lines().filter_map(|l| l.split_whitespace().next()) {
if !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
names
}
/// `lpstat -d` → the default printer ("system default destination: NAME"; none: "no system
/// default destination").
#[cfg(any(test, not(windows)))]
fn lpstat_default(text: &str) -> Option<String> {
let line = text.lines().find(|l| l.contains(':'))?;
let name = line.rsplit_once(':')?.1.trim();
(!name.is_empty()).then(|| name.to_string())
}
/// PowerShell's `<Default>\t<Name>` lines → the printers.
#[cfg(any(test, windows))]
fn windows_printers(text: &str) -> Vec<Printer> {
text.lines()
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
.filter(|(_, name)| !name.trim().is_empty())
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
.collect()
}
/// `s` as a PowerShell single-quoted string.
#[cfg(any(test, windows))]
fn ps_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
#[cfg(not(windows))]
mod system {
use super::*;
pub fn printers() -> Vec<Printer> {
// `-e` lists every destination (CUPS 2); older systems answer `-a`.
let names = output(Command::new("lpstat").arg("-e")).or_else(|_| output(Command::new("lpstat").arg("-a"))).map(|t| lpstat_names(&t));
let default = output(Command::new("lpstat").arg("-d")).ok().and_then(|t| lpstat_default(&t));
names.unwrap_or_default().into_iter().map(|name| Printer { default: default.as_ref() == Some(&name), name }).collect()
}
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
let mut c = Command::new("lp");
if let Some(p) = job.printer {
c.args(["-d", p]);
}
c.args(["-t", job.title]).arg(file);
let r = output(&mut c);
// `lp` has spooled its own copy (or failed).
let _ = std::fs::remove_file(file);
r.map(|out| Some(out.trim().to_string()).filter(|o| !o.is_empty()).map(|o| format!(" ({o})")).unwrap_or_default())
}
pub fn setup(printer: Option<&str>) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let _ = printer;
Command::new("open")
.arg("x-apple.systempreferences:com.apple.Print-Scan-Settings.extension")
.spawn()
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "macos"))]
{
if Command::new("system-config-printer").spawn().is_ok() {
return Ok(());
}
// CUPS' own pages.
let url = printer.map_or_else(|| "http://localhost:631/printers/".to_string(), |p| format!("http://localhost:631/printers/{p}"));
Command::new("xdg-open").arg(url).spawn().map(|_| ()).map_err(|e| e.to_string())
}
}
}
#[cfg(windows)]
mod system {
use super::*;
fn powershell(script: &str) -> Command {
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut c = Command::new("powershell");
c.args(["-NoProfile", "-NonInteractive", "-Command", script]).creation_flags(CREATE_NO_WINDOW);
c
}
pub fn printers() -> Vec<Printer> {
#[cfg(feature = "windows7")]
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
#[cfg(not(feature = "windows7"))]
let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
}
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
let path = ps_quote(&file.to_string_lossy());
let script = match job.printer {
Some(p) => format!(
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
ps_quote(&format!("\"{}\"", p.replace('"', "")))
),
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
};
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
}
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
#[cfg(feature = "windows7")]
let mut command = {
let mut command = Command::new("control.exe");
command.arg("printers");
command
};
#[cfg(not(feature = "windows7"))]
let mut command = {
let mut command = Command::new("explorer");
command.arg("ms-settings:printers");
command
};
command.spawn().map(|_| ()).map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lpstat_lists_names_and_the_default() {
let names = lpstat_names("Office_Laser accepting requests since Mon 05 Oct 2026\nPhoto-Inkjet accepting requests since …\n\n");
assert_eq!(names, ["Office_Laser", "Photo-Inkjet"]);
assert_eq!(lpstat_names("Office_Laser\nOffice_Laser\n"), ["Office_Laser"]);
assert_eq!(lpstat_default("system default destination: Photo-Inkjet\n").as_deref(), Some("Photo-Inkjet"));
assert_eq!(lpstat_default("no system default destination\n"), None);
}
#[test]
fn powershell_lines_become_printers() {
let got = windows_printers("False\tMicrosoft Print to PDF\r\nTrue\tOffice Laser\r\n\r\nFalse\t \r\n");
assert_eq!(got, [Printer { name: "Microsoft Print to PDF".into(), default: false }, Printer { name: "Office Laser".into(), default: true }]);
assert_eq!(ps_quote("C:\\Temp\\it's.pdf"), "'C:\\Temp\\it''s.pdf'");
}
#[test]
fn job_files_hold_the_pdf() {
let path = job_file(b"%PDF-1.7 test").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
let _ = std::fs::remove_file(path);
}
}
+256
View File
@@ -0,0 +1,256 @@
//! The main window's size and position. The first launch fits the window to its monitor, opening
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved
//! onto a monitor that is still connected.
use vectorcraft_ui_egui::state::WindowGeometry;
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
/// The first window's size, in points.
pub const DEFAULT_SIZE: [f32; 2] = [1440.0, 900.0];
/// The smallest window, in points.
pub const MIN_SIZE: [f32; 2] = [800.0, 500.0];
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
/// say how much of the screen they take): a monitor with less room opens it maximized.
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Monitor {
pos: [i64; 2],
size: [i64; 2],
scale: f32,
}
impl Monitor {
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
let scale = scale as f32;
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
}
/// Its size in points.
fn points(&self) -> [f32; 2] {
self.size.map(|s| s as f32 / self.scale)
}
/// Its size in points less room for a taskbar, Dock or menu bar.
fn usable(&self) -> [f32; 2] {
let points = self.points();
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
}
/// A window of `size` points on this monitor, in physical pixels.
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
size.map(|s| (s * self.scale) as i64)
}
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
let px = self.pixels(size);
let span = |i: usize| {
let start = i64::from(pos[i]).max(self.pos[i]);
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
(end - start).max(0)
};
span(0).saturating_mul(span(1))
}
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
/// points on this monitor.
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
let px = self.pixels(size);
std::array::from_fn(|i| {
let lo = self.pos[i];
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
})
}
}
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
/// doesn't list them, so nothing to check a saved geometry against) the default size.
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
return default;
};
let Some(saved) = saved else {
let usable = home.usable();
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
return default;
}
// A small screen: maximized, with a size that fits once un-maximized.
let size = fit(DEFAULT_SIZE, usable);
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
};
// The monitor showing most of the window; none when it was on a monitor that is gone.
let shown = saved.pos.and_then(|p| {
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
(area > 0).then_some(m)
});
let m = shown.unwrap_or(home);
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
let room = m.points();
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
let size = fit(saved.size, if too_big { m.usable() } else { room });
WindowGeometry {
// Moved fully onto its monitor, or centred on the home monitor.
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
size,
maximized: saved.maximized || too_big,
}
}
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
}
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
/// geometry.
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
let handles: Vec<_> = window.available_monitors().collect();
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
let g = place(saved, &monitors, home);
if let Some([x, y]) = g.pos {
window.set_outer_position(PhysicalPosition::new(x, y));
}
// Returns the new size when it applies at once; either way the window gets it.
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
if g.maximized {
window.set_maximized(true);
}
g
}
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
let zoom = ctx.zoom_factor();
ctx.input(|i| {
let v = i.viewport();
if v.minimized == Some(true) || v.fullscreen == Some(true) {
return;
}
if v.maximized == Some(true) {
if let Some(g) = geometry {
g.maximized = true;
}
return;
}
let Some(inner) = v.inner_rect else { return };
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
});
}
#[cfg(test)]
mod tests {
use super::*;
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
}
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
Some(WindowGeometry { pos, size, maximized })
}
#[test]
fn the_first_window_fits_its_screen() {
// 1920 × 1080: the default size, where the system puts it.
let big = [monitor(0, 0, 1920, 1080, 1.0)];
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
// 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points): maximized, and a
// size that fits the screen with room for the taskbar once un-maximized.
for (m, size) in [
(monitor(0, 0, 1600, 900, 1.0), [1440.0, 772.0]),
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
] {
let g = place(None, &[m], Some(0));
assert!(g.maximized, "{m:?}");
assert_eq!(g.size, size, "{m:?}");
let [x, y] = g.pos.unwrap();
let px = m.pixels(g.size);
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
}
// No monitor information: the default size, whatever was saved.
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
}
#[test]
fn the_saved_window_comes_back_where_it_was() {
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)]
{
assert_eq!(Some(place(g, &two, Some(0))), g);
}
}
#[test]
fn a_saved_window_stays_on_a_connected_monitor() {
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
// Last time on a second monitor that is gone: centred on the remaining one.
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
// Half off the monitor's right and bottom edges: moved fully onto it.
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g.pos, Some([600, 200]));
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
// A monitor left of the primary one (negative coordinates) still counts.
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
// Nonsense sizes from a damaged preferences file are kept within the screen.
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
}
#[test]
fn the_window_geometry_is_tracked_for_the_next_launch() {
let ctx = egui::Context::default();
let mut geometry = None;
let mut frame = |info: egui::ViewportInfo| {
let mut input = egui::RawInput::default();
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
geometry
};
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
let normal = egui::ViewportInfo {
native_pixels_per_point: Some(1.5),
inner_rect: Some(rect),
outer_rect: Some(rect),
maximized: Some(false),
..Default::default()
};
// Position in physical pixels, size in points.
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
assert_eq!(frame(normal.clone()), Some(g));
// Maximized or minimized: the size to restore stays.
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
assert_eq!(frame(normal), Some(g));
// Saved with the preferences; older preference files have no window.
let mut ui = vectorcraft_ui_egui::UiState { window: Some(g), ..Default::default() };
let back: vectorcraft_ui_egui::UiState = serde_json::from_slice(&serde_json::to_vec(&ui).unwrap()).unwrap();
assert_eq!(back.window, Some(g));
ui.window = None;
let json = serde_json::to_value(&ui).unwrap();
assert!(json.get("window").is_none());
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
assert_eq!(back.window, None);
}
}
+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="round" stroke-linejoin="round"><path d="M3 20 C5 11 8 7 12 7 S19 11 21 20"/><path d="M5 7 H19"/><circle cx="5" cy="7" r="1.4" fill="currentColor"/><circle cx="19" cy="7" r="1.4" fill="currentColor"/><rect x="10" y="5" width="4" height="4" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 419 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 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 +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="round" stroke-linejoin="round"><path d="M12 19l-7-7 3-6 8 3 3 7-7 3z" /><path d="M5 12l5-5"/><circle cx="11" cy="11" r="1.5"/><path d="M19 3v5M16.5 5.5h5"/></svg>
<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="round" stroke-linejoin="round"><path d="M3 21L21 3" stroke-dasharray="2 2"/><path d="M4 4H16.5L4 16.5Z" fill="currentColor" fill-opacity="0.35"/><path d="M20 7.5V20H7.5Z" fill="currentColor" fill-opacity="0.35"/></svg>
+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="round" stroke-linejoin="round"><path d="M12 2V22" stroke-dasharray="2 2"/><path d="M12 5C7.5 5 4 8 4 12C4 16 7.5 19 12 19Z" fill="currentColor" fill-opacity="0.35"/><path d="M12 5C16.5 5 20 8 20 12C20 16 16.5 19 12 19" stroke-opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 400 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="M11 3V14" /><path d="M14 3V14" /><path d="M16 3H9.5a3 3 0 0 0 0 6H11" /><path d="M4 19H20" /><path d="M17 16L20 19L17 22" /></svg>

After

Width:  |  Height:  |  Size: 322 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="M13 3V14" /><path d="M10 3V14" /><path d="M8 3H14.5a3 3 0 0 1 0 6H13" /><path d="M20 19H4" /><path d="M7 16L4 19L7 22" /></svg>

After

Width:  |  Height:  |  Size: 319 B

-1
View File
@@ -1 +0,0 @@
<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="round" stroke-linejoin="round"><path d="M12 19l-7-7 3-6 8 3 3 7-7 3z" /><path d="M5 12l5-5"/><circle cx="11" cy="11" r="1.5"/><path d="M16.5 5.5h5"/></svg>

Before

Width:  |  Height:  |  Size: 308 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="round" stroke-linejoin="round"><rect x="3" y="4" width="14" height="16" stroke-dasharray="2 2"/><path d="M17 6.34A6 6 0 1 0 17 17.66Z" fill="currentColor" fill-opacity="0.35"/><path d="M17 6.34A6 6 0 0 1 17 17.66" stroke-opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 395 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

+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<!-- Lucide's pen-tool with a plus badge added for VectorCraft -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
<path d="M4 18.3v4.4M1.8 20.5h4.4" />
</svg>

After

Width:  |  Height:  |  Size: 697 B

+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<!-- Lucide's pen-tool with a minus badge added for VectorCraft -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
<path d="M1.8 20.5h4.4" />
</svg>

After

Width:  |  Height:  |  Size: 687 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
+348
View File
@@ -0,0 +1,348 @@
{
"schema": 1,
"repo": "storytold/vectorcraft",
"branch": "main",
"head": "fe9f3afcb7d32032e8f332b0594692a03e275b84",
"head_date": "2026-10-08T00:36:16Z",
"commits": 607,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 15,
"commits": 119,
"lines_added": 172276,
"lines_deleted": 40329,
"binary_added": 37,
"binary_deleted": 2,
"first_commit": "2026-09-30T09:12:46Z",
"last_commit": "2026-10-07T22:04:30Z"
},
{
"login": "bflatastic",
"people_entry": false,
"github_name": null,
"prs": 176,
"commits": 394,
"lines_added": 212929,
"lines_deleted": 24160,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-02T15:22:29Z",
"last_commit": "2026-10-08T00:36:16Z"
},
{
"login": "storybored379",
"people_entry": false,
"github_name": null,
"prs": 7,
"commits": 7,
"lines_added": 456,
"lines_deleted": 49,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-04T06:45:57Z",
"last_commit": "2026-10-04T08:14:20Z"
},
{
"login": "theironchef",
"people_entry": false,
"github_name": null,
"prs": 38,
"commits": 41,
"lines_added": 2803,
"lines_deleted": 232,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T14:40:20Z",
"last_commit": "2026-10-07T22:39:00Z"
},
{
"login": "midasdf",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 579,
"lines_deleted": 43,
"binary_added": 1,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:10:37Z",
"last_commit": "2026-10-07T20:53:30Z"
},
{
"login": "japbcoelho",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 57,
"lines_deleted": 13,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T01:45:49Z",
"last_commit": "2026-10-06T01:45:49Z"
},
{
"login": "p0lyg0n",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 6,
"lines_added": 780,
"lines_deleted": 69,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T09:22:46Z",
"last_commit": "2026-10-07T04:33:45Z"
},
{
"login": "imachus",
"people_entry": false,
"github_name": null,
"prs": 12,
"commits": 12,
"lines_added": 6302,
"lines_deleted": 212,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T10:06:18Z",
"last_commit": "2026-10-07T22:20:10Z"
},
{
"login": "petrleocompel",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 784,
"lines_deleted": 36,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T12:23:11Z",
"last_commit": "2026-10-06T12:23:11Z"
},
{
"login": "fahrim",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 6,
"lines_added": 1964,
"lines_deleted": 76,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T12:48:28Z",
"last_commit": "2026-10-07T23:36:17Z"
},
{
"login": "EthanSuquet",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 413,
"lines_deleted": 71,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T19:30:49Z",
"last_commit": "2026-10-07T14:24:14Z"
},
{
"login": "realwitt",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 607,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T00:12:52Z",
"last_commit": "2026-10-07T00:12:52Z"
},
{
"login": "WildfootW",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 6809,
"lines_deleted": 2627,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T00:37:02Z",
"last_commit": "2026-10-07T00:37:02Z"
},
{
"login": "coygeek",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 4,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T02:29:03Z",
"last_commit": "2026-10-07T02:29:03Z"
},
{
"login": "Luskish",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 261,
"lines_deleted": 32,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T02:50:35Z",
"last_commit": "2026-10-07T02:50:35Z"
},
{
"login": "daniel-efrat",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 547,
"lines_deleted": 43,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T08:13:19Z",
"last_commit": "2026-10-07T08:13:19Z"
},
{
"login": "Pengjingyu1992",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 3132,
"lines_deleted": 4,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T09:56:23Z",
"last_commit": "2026-10-07T09:56:23Z"
},
{
"login": "MillhioreBT",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 564,
"lines_deleted": 41,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T12:58:33Z",
"last_commit": "2026-10-07T12:58:33Z"
},
{
"login": "fabian-co",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 270,
"lines_deleted": 48,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T18:05:26Z",
"last_commit": "2026-10-07T19:32:11Z"
},
{
"login": "Haven-King",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 467,
"lines_deleted": 76,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T18:10:39Z",
"last_commit": "2026-10-07T18:40:49Z"
},
{
"login": "njmueller",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 619,
"lines_deleted": 11,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T21:14:42Z",
"last_commit": "2026-10-07T21:14:42Z"
},
{
"login": "RicSchonfelder",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2592,
"lines_deleted": 9,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T21:44:42Z",
"last_commit": "2026-10-07T21:44:42Z"
},
{
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 76,
"lines_deleted": 21,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T23:09:37Z",
"last_commit": "2026-10-07T23:09:37Z"
}
],
"models": [
{
"id": "anthropic/claude-opus/5.5",
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 567,
"lines_added": 382239,
"lines_deleted": 67174
},
{
"id": "anthropic/claude-sonnet/5.5",
"company": "Anthropic",
"model": "Claude Sonnet",
"version": "5.5",
"commits": 6,
"lines_added": 660,
"lines_deleted": 34
},
{
"id": "anthropic/claude-opus/4.8",
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.8",
"commits": 3,
"lines_added": 1672,
"lines_deleted": 42
},
{
"id": "anthropic/claude-fable/5.1",
"company": "Anthropic",
"model": "Claude Fable",
"version": "5.1",
"commits": 1,
"lines_added": 6809,
"lines_deleted": 2627
}
]
}
+23
View File
@@ -0,0 +1,23 @@
# Contributor names for the About window (craftrules standards/contributors.md).
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
# display_name = "Nickname" # shown when the viewer picks "Display name"
# sync_github_name = true # or: use the name on your public GitHub profile as display name
#
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
+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");
}
}
+218
View File
@@ -0,0 +1,218 @@
//! DXF import: an ASCII DXF drawing of any version as a document.
//!
//! The drawing's model space (or a paper layout) comes in at a ratio (1 unit of the art = N
//! drawing units; by default the drawing's own unit at 1:1) or fitted to an artboard, centred
//! or with the drawing's origin on the artboard's bottom-left corner. Each DXF layer holding art
//! becomes a layer (hidden, frozen, locked and non-plotting layers keep that), or all art goes on
//! one. Lines, polylines with arc segments, circles, arcs, ellipses, splines (NURBS), solid
//! hatches and solids become paths; text and multiline text point type; named blocks symbols
//! (anonymous ones, such as dimensions, groups of their art). Colours (indexed and true colour),
//! lineweights, linetypes and transparency come through layers and blocks as CAD apps resolve
//! them. What can't come in is listed in the warnings.
//!
//! - `reader`: the file's text, group codes, sections, tables, blocks and layouts.
//! - `entity`: entities to items (geometry and properties).
//! - `curve`: bulges, arcs, ellipses and NURBS as Bézier paths.
//! - `text`: text values to plain text.
//! - `build`: items to the document.
mod build;
mod curve;
mod entity;
mod reader;
mod text;
use vectorcraft_doc::{Document, Unit};
pub use reader::BINARY_SENTINEL;
use crate::{DxfVersion, INSUNITS, SCALE_RANGE};
/// Letter, the box Fit to Artboard fills unless told otherwise (points).
pub const FIT_TO: (f64, f64) = (612.0, 792.0);
/// Why a binary DXF can't be opened.
pub const BINARY: &str = "this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that";
/// How a drawing comes in.
#[derive(Clone, Debug, PartialEq)]
pub struct ImportOptions {
/// The layout (any case): `Model` (`None`, the default) or a paper layout's name.
pub layout: Option<String>,
/// Scale the art to fit `fit_to` (its larger side along the art's), instead of the ratio.
pub fit: bool,
/// The artboard Fit fills (points, `width × height`).
pub fit_to: (f64, f64),
/// The ratio: 1 `unit` of the art is `scale` drawing units. `None`: the drawing's own unit
/// at 1:1 (millimetres for unitless metric drawings, inches for imperial ones).
pub unit: Option<Unit>,
pub scale: Option<f64>,
/// Lineweights scale with the art (else they keep their printed widths).
pub scale_lineweights: bool,
/// The art's centre on the artboard's (else the drawing's origin, or fitted art's
/// bottom-left corner, on the artboard's bottom-left corner).
pub center: bool,
/// All art on one layer.
pub merge_layers: bool,
}
impl Default for ImportOptions {
fn default() -> Self {
Self { layout: None, fit: false, fit_to: FIT_TO, unit: None, scale: None, scale_lineweights: false, center: true, merge_layers: false }
}
}
/// An imported drawing.
#[derive(Clone, Debug)]
pub struct Imported {
pub document: Document,
/// What was approximated or left out.
pub warnings: Vec<String>,
}
/// What a drawing holds, for the import options.
#[derive(Clone, Debug, PartialEq)]
pub struct DxfInfo {
/// The DXF version (`2018`, `R12`…; the `$ACADVER` code when it's none of the known ones).
pub version: String,
/// The drawing unit's name (`Millimeters`, `Unitless`…).
pub units: String,
/// `Model`, then the paper layouts.
pub layouts: Vec<String>,
/// The layer table's layers.
pub layers: Vec<String>,
/// The default ratio: 1 `unit` = `scale` drawing units.
pub unit: Unit,
pub scale: f64,
}
/// Does `bytes` look like a DXF file (ASCII or binary)?
pub fn is_dxf(bytes: &[u8]) -> bool {
if bytes.starts_with(reader::BINARY_SENTINEL) {
return true;
}
let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(1024)).unwrap_or(bytes));
let mut lines = head.trim_start_matches('\u{feff}').lines().map(str::trim).skip_while(|l| l.is_empty());
// Comments (999) may come first; then the first section.
while let (Some(code), Some(value)) = (lines.next(), lines.next()) {
match code {
"999" => continue,
"0" => return value == "SECTION",
_ => return false,
}
}
false
}
/// The pairs of a DXF file and where it is damaged (binary and DWG files are refused).
fn text(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, String> {
if bytes.starts_with(reader::BINARY_SENTINEL) {
return Err(BINARY.into());
}
if bytes.starts_with(b"AC10") || bytes.starts_with(b"AC1.") {
return Err("this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that".into());
}
Ok(reader::decode(bytes))
}
/// The unit of `$INSUNITS` code `code` and the scale that shows the drawing 1:1 in it.
fn default_ratio(h: &reader::Header) -> (Unit, f64) {
if let Some((_, u)) = INSUNITS.iter().find(|(c, _)| i64::from(*c) == h.insunits) {
return (*u, 1.0);
}
match build::insunits_points(h.insunits) {
Some(pt) => (Unit::Millimeters, Unit::Millimeters.points() / pt),
None if h.measurement == Some(0) => (Unit::Inches, 1.0),
None => (Unit::Millimeters, 1.0),
}
}
/// The name of `$INSUNITS` code `code`.
fn units_name(code: i64) -> &'static str {
const NAMES: [&str; 22] = [
"Unitless",
"Inches",
"Feet",
"Miles",
"Millimeters",
"Centimeters",
"Meters",
"Kilometers",
"Microinches",
"Mils",
"Yards",
"Angstroms",
"Nanometers",
"Microns",
"Decimeters",
"Decameters",
"Hectometers",
"Gigameters",
"Astronomical units",
"Light years",
"Parsecs",
"US survey feet",
];
usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless")
}
/// Read what `bytes` holds: its version, units, layouts and layers.
pub fn info(bytes: &[u8]) -> Result<DxfInfo, String> {
let text = text(bytes)?;
let (pairs, _) = reader::pairs(&text)?;
let d = reader::Drawing::read(&pairs);
if d.sections == 0 {
return Err("not a DXF drawing".into());
}
let h = &d.header;
let (unit, scale) = default_ratio(h);
let version = DxfVersion::ALL.into_iter().find(|v| v.acadver() == h.acadver).map_or_else(|| h.acadver.clone(), |v| v.id().to_string());
Ok(DxfInfo {
version,
units: units_name(h.insunits).into(),
layouts: d.layouts.iter().map(|l| l.name.clone()).collect(),
layers: d.layers.iter().map(|l| l.name.clone()).collect(),
unit,
scale,
})
}
/// Import an ASCII DXF drawing with `o`.
pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
let (w, h) = o.fit_to;
if !(w > 0.0 && h > 0.0 && w.max(h) <= build::MAX_EXTENT) {
return Err(format!("the artboard to fit must be a positive size up to {} pt", build::MAX_EXTENT));
}
if let Some(s) = o.scale
&& !(s.is_finite() && SCALE_RANGE.contains(&s))
{
return Err(format!("scale must be a number from {} to {}, not {s}", SCALE_RANGE.start(), SCALE_RANGE.end()));
}
let text = text(bytes)?;
let (pairs, damaged) = reader::pairs(&text)?;
let d = reader::Drawing::read(&pairs);
if d.sections == 0 {
return Err("not a DXF drawing".into());
}
let layout = match &o.layout {
None => d.layouts.first(),
Some(name) => d.layout(name),
};
let Some(layout) = layout else {
let names: Vec<&str> = d.layouts.iter().map(|l| l.name.as_str()).collect();
return Err(format!("the drawing has no layout `{}` (it has {})", o.layout.as_deref().unwrap_or_default(), names.join(", ")));
};
let (unit, scale) = default_ratio(&d.header);
let natural = unit.points() / scale;
let ratio = match (o.unit, o.scale) {
(None, None) => natural,
(u, s) => u.unwrap_or(unit).points() / s.unwrap_or(1.0),
};
let (mut document, mut warnings) = build::Build::new(&d, o).run(layout, natural, ratio)?;
document.units = if o.fit { unit } else { o.unit.unwrap_or(unit) };
warnings.extend(damaged);
Ok(Imported { document, warnings })
}
#[cfg(test)]
mod tests;
+615
View File
@@ -0,0 +1,615 @@
//! Drawing items to a document: the scale and position the options ask for, one layer per DXF
//! layer that holds art (or one in all), named blocks as symbols (anonymous ones, such as
//! dimensions, as groups), and colours, lineweights, linetypes and transparency resolved through
//! layers and blocks.
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use kurbo::BezPath;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Dash, Document, LAYER_COLORS, LayerColor, LineCap, Node, NodeKind, Symbol, TextObject};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
use super::ImportOptions;
use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, Span, TextItem};
use super::reader::{Drawing, LayoutDef};
use crate::{CAP_HEIGHT, MAX_NEST};
/// Points per millimetre.
const PT_PER_MM: f64 = 72.0 / 25.4;
/// The stroke weight of lineweight 0 (CAD apps draw it one device pixel wide), in points.
const THINNEST: f64 = 0.25;
/// The most objects an import makes.
const MAX_NODES: usize = 2_000_000;
/// How far from the origin art may lie (points): the canvas.
pub(crate) const MAX_EXTENT: f64 = 4.0e6;
const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are left out";
const XREFS: &str = "blocks referencing other drawings (external references) are left out";
const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out";
/// What a block's art takes from the insert that places it: its colour, lineweight, linetype
/// and opacity where the art says "by block", and its layer for art on layer 0.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
struct Inherit {
color: Option<[u8; 3]>,
lineweight: Option<i32>,
linetype: Option<String>,
alpha: Option<u8>,
layer: Option<String>,
}
pub(crate) struct Build<'d, 'a> {
d: &'d Drawing<'a>,
o: &'d ImportOptions,
conv: Converter,
doc: Document,
/// Block items by upper-case name.
blocks: HashMap<String, Rc<Vec<Item>>>,
bounds: HashMap<String, Option<Rect>>,
sensitive: HashMap<String, bool>,
/// Symbol names by block and what it inherits (`None`: the block has no art).
symbols: HashMap<(String, Inherit), Option<String>>,
/// Blocks being drawn, outermost first.
stack: Vec<String>,
/// Document space per drawing unit, and its symbols' (no translation).
map: Affine,
symbol_map: Affine,
/// Lineweights are multiplied by this.
lw_scale: f64,
nodes: usize,
warnings: Vec<String>,
}
/// The unit of `$INSUNITS` code `code` in points (0, unitless: none).
pub(crate) fn insunits_points(code: i64) -> Option<f64> {
let m = 72.0 / 0.0254;
Some(match code {
1 => 72.0,
2 => 864.0,
3 => 72.0 * 63_360.0,
4 => PT_PER_MM,
5 => PT_PER_MM * 10.0,
6 => m,
7 => m * 1e3,
8 => 72e-6,
9 => 72e-3,
10 => 2592.0,
11 => m * 1e-10,
12 => m * 1e-9,
13 => m * 1e-6,
14 => m * 0.1,
15 => m * 10.0,
16 => m * 100.0,
17 => m * 1e9,
18 => m * 1.495_978_707e11,
19 => m * 9.460_730_472_580_8e15,
20 => m * 3.085_677_581e16,
21 => m * 1200.0 / 3937.0,
_ => return None,
})
}
impl<'d, 'a> Build<'d, 'a> {
pub fn new(d: &'d Drawing<'a>, o: &'d ImportOptions) -> Self {
Self {
d,
o,
conv: Converter::default(),
doc: Document::new(1.0, 1.0),
blocks: HashMap::new(),
bounds: HashMap::new(),
sensitive: HashMap::new(),
symbols: HashMap::new(),
stack: vec![],
map: Affine::IDENTITY,
symbol_map: Affine::IDENTITY,
lw_scale: 1.0,
nodes: 0,
warnings: vec![],
}
}
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
/// Import `layout`; `natural` and `ratio` are points per drawing unit in the drawing's own
/// unit and at the scale asked for (fitting computes its own).
pub fn run(mut self, layout: &LayoutDef, natural: f64, ratio: f64) -> Result<(Document, Vec<String>), String> {
let items = self.conv.items(&self.d.layout_records(layout));
let bounds = items.iter().fold(None, |acc, it| vectorcraft_geom::union_opt(acc, self.item_bounds(it, 0)));
let (k, size, offset) = self.placement(bounds, natural, ratio)?;
self.map = Affine::new([k, 0.0, 0.0, -k, offset.x, offset.y]);
self.symbol_map = Affine::new([k, 0.0, 0.0, -k, 0.0, 0.0]);
self.lw_scale = if self.o.scale_lineweights { k / natural } else { 1.0 };
let layers = self.layers(&items);
let Self { mut doc, conv, warnings: notes, .. } = self;
if !layers.is_empty() {
doc.layers = layers;
}
if let Some(a) = doc.artboards.first_mut() {
a.rect = Rect::from_origin_size(Point::ZERO, size);
}
doc.fix_next_id();
let mut warnings = conv.warnings;
warnings.extend(notes);
if !conv.skipped.is_empty() {
let list: Vec<String> = conv.skipped.iter().map(|(k, n)| format!("{n} {k}")).collect();
warnings.push(format!("entities of these types are left out: {}", list.join(", ")));
}
if bounds.is_none() {
warnings.push(format!("the {} layout has no art", layout.name));
}
Ok((doc, warnings))
}
/// Points per drawing unit, the artboard's size, and where the drawing's origin goes.
fn placement(&self, bounds: Option<Rect>, natural: f64, ratio: f64) -> Result<(f64, vectorcraft_geom::Size, Point), String> {
let b = bounds.unwrap_or(Rect::ZERO);
let k = if self.o.fit {
let (w, h) = self.fit_box(b);
let ratio = |a: f64, b: f64| (b > 1e-12).then(|| a / b);
[ratio(w, b.width()), ratio(h, b.height())].into_iter().flatten().reduce(f64::min).unwrap_or(natural)
} else {
ratio
};
if !(k.is_finite() && k > 0.0) {
return Err("the scale is not a usable number".into());
}
let size = if self.o.fit {
let (w, h) = self.fit_box(b);
vectorcraft_geom::Size::new(w, h)
} else {
vectorcraft_geom::Size::new((b.width() * k).max(1.0), (b.height() * k).max(1.0))
};
// Centred: the art's centre on the artboard's; else the drawing's origin (fitted art: its
// bottom-left corner) on the artboard's bottom-left corner.
let offset = if self.o.center {
Point::new(size.width / 2.0 - b.center().x * k, size.height / 2.0 + b.center().y * k)
} else if self.o.fit {
Point::new(-b.x0 * k, size.height + b.y0 * k)
} else {
Point::new(0.0, size.height)
};
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)].map(|p| Point::new(p.x * k + offset.x, -p.y * k + offset.y));
let fits = corners.iter().all(|p| p.x.abs() <= MAX_EXTENT && p.y.abs() <= MAX_EXTENT) && size.width.max(size.height) <= MAX_EXTENT;
if !fits {
let (w, h) = (b.width() * k, b.height() * k);
return Err(format!(
"at this scale the drawing is {w:.0} × {h:.0} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard"
));
}
Ok((k, size, offset))
}
/// The box Fit to Artboard fills, turned to the art's orientation.
fn fit_box(&self, b: Rect) -> (f64, f64) {
let (w, h) = self.o.fit_to;
if (b.width() > b.height()) == (w > h) { (w, h) } else { (h, w) }
}
// ---------- blocks ----------
/// A block's items (read once).
fn block(&mut self, name: &str) -> Option<Rc<Vec<Item>>> {
if let Some(b) = self.blocks.get(name) {
return Some(b.clone());
}
let def = self.d.blocks.get(name)?;
if def.xref {
self.warn(XREFS);
return None;
}
let items = Rc::new(self.conv.items(&def.records));
self.blocks.insert(name.to_string(), items.clone());
Some(items)
}
/// Enter block `name` `depth` deep; false (with a warning) when too deep or inside itself.
fn enter(&mut self, name: &str, depth: u32) -> bool {
if depth >= MAX_NEST || self.stack.iter().any(|s| s == name) {
self.warn(NESTED);
return false;
}
self.stack.push(name.to_string());
true
}
/// A block's bounds in its coordinates less its base point.
fn block_bounds(&mut self, name: &str, depth: u32) -> Option<Rect> {
if let Some(b) = self.bounds.get(name) {
return *b;
}
let items = self.block(name)?;
if !self.enter(name, depth) {
return None;
}
let b = items.iter().fold(None, |acc, it| vectorcraft_geom::union_opt(acc, self.item_bounds(it, depth + 1)));
self.stack.pop();
let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base);
let b = b.map(|r| r - base.to_vec2());
self.bounds.insert(name.to_string(), b);
b
}
/// An item's bounds in world coordinates (type: an estimate from its height and length).
fn item_bounds(&mut self, it: &Item, depth: u32) -> Option<Rect> {
match &it.geom {
Geom::Path { bp, .. } => super::curve::bounds(bp),
Geom::Text(t) => {
let lines = t.text.lines().count().max(1) as f64;
let longest = t.text.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64;
let natural = longest * t.height * 0.9 * t.width;
// Fit and aligned text run their span; aligned text's height scales with it.
let k = t.span.and_then(|sp| sp.scale(natural)).unwrap_or(1.0);
let height = if matches!(t.span, Some(Span::Aligned(_))) { t.height * k } else { t.height };
let w = natural * k;
let x0 = match t.justify {
vectorcraft_doc::Justify::Center => -w / 2.0,
vectorcraft_doc::Justify::Right => -w,
_ => 0.0,
};
let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + height * 0.3;
Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, height)))
}
Geom::Insert(ins) => {
let b = self.block_bounds(&ins.block, depth);
let cells = b.into_iter().flat_map(|b| ins.cells.iter().map(move |m| m.transform_rect_bbox(b)));
let attribs: Vec<Option<Rect>> = ins.attribs.iter().map(|a| self.item_bounds(a, depth)).collect();
cells.map(Some).chain(attribs).fold(None, vectorcraft_geom::union_opt)
}
}
.filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()))
}
/// Does a block's look depend on its insert (art "by block", or on layer 0 "by layer")?
fn is_sensitive(&mut self, name: &str, depth: u32) -> bool {
if let Some(s) = self.sensitive.get(name) {
return *s;
}
let Some(items) = self.block(name) else { return false };
if !self.enter(name, depth) {
return false;
}
let mut s = false;
for it in items.iter() {
let p = &it.props;
let on_zero = p.layer == "0";
s |= p.color == Col::ByBlock || p.lineweight == Lw::ByBlock || p.linetype == "BYBLOCK" || p.alpha == Alpha::ByBlock;
s |= on_zero && (p.color == Col::ByLayer || p.lineweight == Lw::ByLayer || p.linetype == "BYLAYER");
if let Geom::Insert(ins) = &it.geom {
s |= !ins.attribs.is_empty() || self.is_sensitive(&ins.block, depth + 1);
}
if s {
break;
}
}
self.stack.pop();
self.sensitive.insert(name.to_string(), s);
s
}
// ---------- properties ----------
/// The layer art on layer 0 takes from its block, else its own.
fn layer_name<'p>(&self, p: &'p Props, inh: &'p Inherit) -> &'p str {
match &inh.layer {
Some(l) if p.layer == "0" => l,
_ => &p.layer,
}
}
fn rgb(&self, p: &Props, inh: &Inherit) -> [u8; 3] {
match p.color {
Col::Rgb(c) => c,
Col::ByBlock => inh.color.unwrap_or([0; 3]),
Col::ByLayer => self.d.layer(self.layer_name(p, inh)).map_or([0; 3], |l| l.rgb),
}
}
/// The lineweight in hundredths of a millimetre.
fn lineweight(&self, p: &Props, inh: &Inherit) -> i32 {
let default = self.d.header.lwdefault;
match p.lineweight {
Lw::Hundredths(v) => v,
Lw::Default => default,
Lw::ByBlock => inh.lineweight.unwrap_or(default),
Lw::ByLayer => self.d.layer(self.layer_name(p, inh)).map(|l| l.lineweight).filter(|v| *v >= 0).unwrap_or(default),
}
}
/// The linetype name (upper case) after "by layer" and "by block".
fn linetype(&self, p: &Props, inh: &Inherit) -> String {
match p.linetype.as_str() {
"BYLAYER" => self.d.layer(self.layer_name(p, inh)).map_or_else(|| "CONTINUOUS".into(), |l| l.linetype.clone()),
"BYBLOCK" => inh.linetype.clone().unwrap_or_else(|| "CONTINUOUS".into()),
other => other.to_string(),
}
}
fn opacity(&self, p: &Props, inh: &Inherit) -> f32 {
match p.alpha {
Alpha::Opacity(a) => a,
Alpha::ByBlock => inh.alpha.map_or(1.0, |a| f32::from(a) / 255.0),
Alpha::ByLayer => 1.0,
}
}
/// What a block placed by an insert with `p` inherits.
fn inherit(&self, p: &Props, inh: &Inherit) -> Inherit {
Inherit {
color: Some(self.rgb(p, inh)),
lineweight: Some(self.lineweight(p, inh)),
linetype: Some(self.linetype(p, inh)),
alpha: Some((self.opacity(p, inh) * 255.0).round() as u8),
layer: Some(self.layer_name(p, inh).to_string()),
}
}
/// A dash pattern in points for `linetype` drawn `scale` points per drawing unit.
fn dash(&self, linetype: &str, p: &Props, scale: f64) -> Option<(Dash, bool)> {
let pattern = self.d.linetypes.get(linetype)?;
let k = self.d.header.ltscale * p.ltscale * scale;
// Dashes (positive), gaps (negative) and dots (0), merged into dash, gap, dash, gap…
let mut out: Vec<f64> = vec![];
for v in pattern {
let dash = *v >= 0.0;
let len = v.abs() * k;
match out.len().is_multiple_of(2) {
expects_dash if expects_dash == dash => out.push(len),
_ if out.is_empty() => out.extend([0.0, len]),
_ => {
if let Some(last) = out.last_mut() {
*last += len;
}
}
}
}
if !out.len().is_multiple_of(2) {
out.push(0.0);
}
let dots = out.iter().step_by(2).any(|d| *d <= 1e-9);
let d = Dash { pattern: out, offset: 0.0, align_corners: false };
(d.is_dashed() && d.pattern.iter().skip(1).step_by(2).any(|g| *g > 1e-9) && d.pattern.iter().all(|v| v.is_finite())).then_some((d, dots))
}
// ---------- art ----------
/// One doc layer per DXF layer holding art (in the order art first uses them), or one for all.
fn layers(&mut self, items: &[Item]) -> Vec<Arc<Node>> {
let mut layers: Vec<(String, Vec<Arc<Node>>)> = vec![];
let mut index: HashMap<String, usize> = HashMap::new();
let top = Inherit::default();
let map = self.map;
for it in items {
let Some(node) = self.item(it, map, &top, 0) else { continue };
let name = if self.o.merge_layers { "" } else { it.props.layer.as_str() };
let i = *index.entry(name.to_uppercase()).or_insert_with(|| {
layers.push((name.to_string(), vec![]));
layers.len() - 1
});
if let Some((_, children)) = layers.get_mut(i) {
children.push(Arc::new(node));
}
}
layers
.into_iter()
.enumerate()
.map(|(i, (name, children))| {
let id = self.doc.alloc_id();
let color = LayerColor::Preset((i % LAYER_COLORS.len()) as u8);
let label = if name.is_empty() { "Layer 1" } else { &name };
let mut l = Node::layer(id, label, color);
if let Some(def) = self.d.layer(&name) {
l.visible = !def.hidden;
l.locked = def.locked;
if let NodeKind::Layer { printable, .. } = &mut l.kind {
*printable = def.plot;
}
}
if let Some(c) = l.children_mut() {
*c = children;
}
Arc::new(l)
})
.collect()
}
/// One item as an object, drawn by `map` (world coordinates to its parent's space).
fn item(&mut self, it: &Item, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
if self.nodes >= MAX_NODES {
self.warn(TOO_MANY);
return None;
}
let mut node = match &it.geom {
Geom::Path { bp, fill, width } => self.path(bp, *fill, *width, &it.props, map, inh)?,
Geom::Text(t) => self.text(t, &it.props, map, inh)?,
Geom::Insert(ins) => return self.insert(ins, &it.props, map, inh, depth),
};
node.opacity = self.opacity(&it.props, inh);
self.nodes += 1;
Some(node)
}
fn path(&mut self, bp: &BezPath, fill: bool, width: f64, p: &Props, map: Affine, inh: &Inherit) -> Option<Node> {
let mut bp = bp.clone();
bp.apply_affine(map);
super::curve::bounds(&bp)?;
let paint = Paint::solid(rgb_color(self.rgb(p, inh)));
let scale = map.determinant().abs().sqrt();
let appearance = if fill {
Appearance::basic(paint, Paint::None, 0.0)
} else {
let lw = self.lineweight(p, inh);
let w = if width > 0.0 {
width * scale
} else if lw == 0 {
THINNEST
} else {
f64::from(lw) / 100.0 * PT_PER_MM * self.lw_scale
};
let mut ap = Appearance::basic(Paint::None, paint, w);
if let Some(AppearanceItem::Stroke(s)) = ap.items.last_mut()
&& let Some((dash, dots)) = self.dash(&self.linetype(p, inh), p, scale)
{
s.dash = Some(dash);
if dots {
s.cap = LineCap::Round;
}
}
ap
};
let rule = if fill { FillRule::EvenOdd } else { FillRule::NonZero };
let path = PathData::from_bezpath(&bp);
let kind = |path: PathData| NodeKind::Path { path, rule, live: None, clipping: false, guide: false };
let mut n = if path.subpaths.len() > 1 {
// Several loops (a hatch with islands) paint as one compound path.
let children = path
.subpaths
.into_iter()
.map(|sp| {
let mut c = Node::new(self.doc.alloc_id(), kind(PathData::single(sp)));
c.appearance = appearance.clone();
Arc::new(c)
})
.collect();
Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule })
} else {
Node::new(self.doc.alloc_id(), kind(path))
};
n.appearance = appearance;
Some(n)
}
fn text(&mut self, t: &TextItem, p: &Props, map: Affine, inh: &Inherit) -> Option<Node> {
// Text space is y-down points; the item's is y-up drawing units.
let m = map * t.xf * Affine::FLIP_Y;
let s = m.determinant().abs().sqrt();
if !(s > 1e-12 && s.is_finite()) {
return None;
}
let family = t.family.clone().or_else(|| self.d.styles.get(&t.style).filter(|f| !f.is_empty()).cloned());
let default = CharStyle::default();
let style = CharStyle {
font_family: family.unwrap_or(default.font_family.clone()),
size: t.height / CAP_HEIGHT * s,
h_scale: t.width * 100.0,
leading: t.leading.map(|l| l * s),
fill: Paint::solid(rgb_color(self.rgb(p, inh))),
..default
};
let mut obj = TextObject::point(Point::ZERO, &t.text, style);
obj.xf = m * Affine::scale(1.0 / s);
obj.para.justify = t.justify;
if let Some(span) = t.span {
fill_span(&mut obj, span, s);
}
Some(Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))))
}
/// An insert: a symbol instance per copy (anonymous blocks: a group of their art), grouped
/// with its attributes.
fn insert(&mut self, ins: &InsertItem, p: &Props, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
let child = self.inherit(p, inh);
let base = self.d.blocks.get(&ins.block).map_or(Point::ZERO, |b| b.base);
let unbase = Affine::translate(-base.to_vec2());
let anonymous = ins.block.starts_with('*');
let symbol = if anonymous { None } else { self.symbol(&ins.block, &child, depth) };
let mut parts = vec![];
for cell in &ins.cells {
let node = match &symbol {
Some(name) => {
let xf = map * *cell * self.symbol_map.inverse();
self.nodes += 1;
Some(Node::new(self.doc.alloc_id(), NodeKind::SymbolInstance { symbol: name.clone(), xf }))
}
None if anonymous => self.art(&ins.block, map * *cell * unbase, &child, depth),
None => None,
};
parts.extend(node);
}
for a in &ins.attribs {
parts.extend(self.item(a, map, &child, depth));
}
let mut node = match parts.len() {
0 => return None,
1 => parts.pop()?,
_ => Node::group(self.doc.alloc_id(), parts.into_iter().map(Arc::new).collect()),
};
node.opacity = self.opacity(p, inh);
Some(node)
}
/// The art of block `name` drawn by `map`, as one object.
fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
let items = self.block(name)?;
if !self.enter(name, depth) {
return None;
}
let mut nodes = vec![];
for it in items.iter() {
// Art on a hidden layer stays hidden inside blocks.
if self.d.layer(self.layer_name(&it.props, inh)).is_some_and(|l| l.hidden) && it.props.layer != "0" {
continue;
}
nodes.extend(self.item(it, map, inh, depth + 1));
}
self.stack.pop();
match nodes.len() {
0 => None,
1 => nodes.pop(),
_ => Some(Node::group(self.doc.alloc_id(), nodes.into_iter().map(Arc::new).collect())),
}
}
/// The symbol for block `name` placed with `inh` (made once per look).
fn symbol(&mut self, name: &str, inh: &Inherit, depth: u32) -> Option<String> {
let inh = if self.is_sensitive(name, depth) { inh.clone() } else { Inherit::default() };
let key = (name.to_string(), inh);
if let Some(s) = self.symbols.get(&key) {
return s.clone();
}
let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base);
let map = self.symbol_map * Affine::translate(-base.to_vec2());
let art = self.art(name, map, &key.1, depth);
let made = art.map(|art| {
let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str());
let symbol = unique(label, |n| self.doc.symbols.iter().any(|s| s.name == n));
self.doc.symbols.push(Symbol { name: symbol.clone(), art: Arc::new(art) });
symbol
});
self.symbols.insert(key, made.clone());
made
}
}
/// Fit text stretched or squeezed across, aligned text scaled as a whole, to run its span
/// (`s`: points per drawing unit), measured in the font it is set in.
fn fill_span(obj: &mut TextObject, span: Span, s: f64) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj);
let natural = layout.lines.iter().map(|l| l.x1 - l.x0).fold(0.0, f64::max);
let Some(k) = span.scale(natural / s) else { return };
for r in &mut obj.runs {
match span {
Span::Fit(_) => r.style.h_scale *= k,
Span::Aligned(_) => r.style.size *= k,
}
}
}
fn rgb_color([r, g, b]: [u8; 3]) -> Color {
Color::rgb8(r, g, b)
}
/// `base`, or `base 2`, `base 3`… when taken.
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
+381
View File
@@ -0,0 +1,381 @@
//! CAD curves as Bézier paths: polylines with bulges (arc segments), circular and elliptical
//! arcs, and splines (NURBS of any degree, rational or not, or a curve through fit points).
//!
//! Curves are built in drawing units with tolerances relative to their own size, so they keep
//! their accuracy at any scale the drawing is imported at.
use kurbo::{Arc, BezPath, Shape, Vec2};
use vectorcraft_geom::{Affine, Point};
/// Arcs are approximated to within this fraction of their radius.
const ARC_TOLERANCE: f64 = 1e-6;
/// Splines are approximated to within this fraction of their control polygon's size.
const SPLINE_TOLERANCE: f64 = 1e-6;
/// The most Bézier pieces one spline span may become.
const MAX_DEPTH: u32 = 12;
/// The most path elements one spline may become (beyond, its pieces aren't split further).
const MAX_ELEMENTS: usize = 200_000;
/// The highest spline degree read.
const MAX_DEGREE: usize = 11;
/// A polyline vertex: its point and the bulge of the segment that starts there (the tangent of a
/// quarter of the arc's angle; positive turns counter-clockwise).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Vertex {
pub p: Point,
pub bulge: f64,
}
/// A polyline: straight segments, and arcs where a vertex has a bulge.
pub(crate) fn polyline(verts: &[Vertex], closed: bool) -> Option<BezPath> {
let first = verts.first()?;
let mut bp = BezPath::new();
bp.move_to(first.p);
let n = verts.len();
let segs = if closed { n } else { n - 1 };
for i in 0..segs {
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { break };
bulge_to(&mut bp, a.p, b.p, a.bulge);
}
if closed {
bp.close_path();
}
Some(bp)
}
/// A segment from `a` (the current point) to `b`, an arc when `bulge` isn't 0.
fn bulge_to(bp: &mut BezPath, a: Point, b: Point, bulge: f64) {
let chord = b - a;
let len = chord.hypot();
if bulge.abs() < 1e-12 || len < 1e-12 || !bulge.is_finite() {
bp.line_to(b);
return;
}
// The included angle is 4·atan(bulge); the centre lies off the chord's middle.
let sweep = 4.0 * bulge.atan();
let radius = len / (2.0 * (sweep / 2.0).sin()).abs();
let normal = Vec2::new(-chord.y, chord.x) / len;
let offset = (radius * radius - len * len / 4.0).max(0.0).sqrt();
// A counter-clockwise arc under a half turn has its centre left of the chord.
let side = if (bulge > 0.0) == (bulge.abs() < 1.0) { 1.0 } else { -1.0 };
let centre = a.midpoint(b) + normal * offset * side;
let start = (a - centre).atan2();
arc_to(bp, centre, radius, start, sweep);
}
/// An arc of a circle from angle `start` (radians) through `sweep` (positive: counter-clockwise),
/// from the current point.
fn arc_to(bp: &mut BezPath, centre: Point, radius: f64, start: f64, sweep: f64) {
let arc = Arc::new(centre, (radius, radius), start, sweep, 0.0);
for el in arc.append_iter((radius * ARC_TOLERANCE).max(1e-12)) {
bp.push(el);
}
}
/// A circular arc from `start` through `sweep` radians as its own subpath; a whole circle closes.
pub(crate) fn arc(centre: Point, radius: f64, start: f64, sweep: f64) -> Option<BezPath> {
if !(radius > 0.0 && radius.is_finite() && sweep.is_finite()) {
return None;
}
let full = sweep.abs() >= std::f64::consts::TAU - 1e-9;
let sweep = sweep.clamp(-std::f64::consts::TAU, std::f64::consts::TAU);
let mut bp = BezPath::new();
bp.move_to(centre + Vec2::from_angle(start) * radius);
arc_to(&mut bp, centre, radius, start, sweep);
if full {
bp.close_path();
}
Some(bp)
}
/// An elliptical arc: the unit circle's arc from `start` through `sweep` radians mapped by `frame`
/// (its columns are the major and minor semi-axes, its translation the centre).
pub(crate) fn ellipse(frame: Affine, start: f64, sweep: f64) -> Option<BezPath> {
let [a, b, c, d, _, _] = frame.as_coeffs();
let size = a.hypot(b).max(c.hypot(d));
if !(size > 1e-12 && size.is_finite()) {
return None;
}
let full = sweep.abs() >= std::f64::consts::TAU - 1e-9;
let sweep = sweep.clamp(-std::f64::consts::TAU, std::f64::consts::TAU);
let unit = Arc::new(Point::ZERO, (1.0, 1.0), start, sweep, 0.0);
let mut bp = BezPath::new();
bp.move_to(Point::ZERO + Vec2::from_angle(start));
for el in unit.append_iter(ARC_TOLERANCE) {
bp.push(el);
}
if full {
bp.close_path();
}
bp.apply_affine(frame);
Some(bp)
}
/// A point with a weight, in homogeneous form (`x·w, y·w, w`).
#[derive(Clone, Copy, Debug)]
struct H {
x: f64,
y: f64,
w: f64,
}
impl H {
fn lerp(self, o: Self, t: f64) -> Self {
Self { x: self.x + (o.x - self.x) * t, y: self.y + (o.y - self.y) * t, w: self.w + (o.w - self.w) * t }
}
fn point(self) -> Option<Point> {
(self.w.abs() > 1e-300).then(|| Point::new(self.x / self.w, self.y / self.w)).filter(|p| p.x.is_finite() && p.y.is_finite())
}
}
/// A NURBS curve of `degree` with `knots`, control points `ctrl` and their `weights` (none:
/// all 1) as Bézier pieces: exact for polynomial curves up to degree 3, else within
/// [`SPLINE_TOLERANCE`] of the control polygon's size.
pub(crate) fn nurbs(degree: usize, knots: &[f64], ctrl: &[Point], weights: Option<&[f64]>) -> Option<BezPath> {
let n = ctrl.len();
if !(1..=MAX_DEGREE).contains(&degree) || n <= degree || knots.len() != n + degree + 1 {
return None;
}
if knots.windows(2).any(|w| w[1] < w[0]) {
return None;
}
let weight = |i: usize| weights.and_then(|w| w.get(i)).copied().filter(|w| *w > 0.0 && w.is_finite()).unwrap_or(1.0);
let hs: Vec<H> = ctrl.iter().enumerate().map(|(i, p)| H { x: p.x * weight(i), y: p.y * weight(i), w: weight(i) }).collect();
let rational = (0..n).any(|i| (weight(i) - weight(0)).abs() > 1e-12);
let (lo, hi) = ctrl.iter().fold((Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)), |(lo, hi), p| {
(Point::new(lo.x.min(p.x), lo.y.min(p.y)), Point::new(hi.x.max(p.x), hi.y.max(p.y)))
});
let tol = ((hi - lo).hypot() * SPLINE_TOLERANCE).max(1e-12);
let mut bp = BezPath::new();
// Each knot span of the domain is one polynomial piece: its Bézier points are blossoms.
for span in degree..n {
let (Some(&t0), Some(&t1)) = (knots.get(span), knots.get(span + 1)) else { continue };
if t1 - t0 <= 1e-12 * (1.0 + t0.abs()) {
continue;
}
let bez: Vec<H> = (0..=degree)
.map(|j| {
let args: Vec<f64> = (0..degree).map(|k| if k < degree - j { t0 } else { t1 }).collect();
blossom(degree, knots, &hs, span, &args)
})
.collect::<Option<_>>()?;
let start = bez.first().and_then(|h| h.point())?;
if bp.elements().is_empty() {
bp.move_to(start);
}
let pts: Option<Vec<Point>> = if rational { None } else { bez.iter().map(|h| h.point()).collect() };
match pts.as_deref() {
Some([_, p]) => bp.line_to(*p),
Some([_, q, p]) => bp.quad_to(*q, *p),
Some([_, a, b, p]) => bp.curve_to(*a, *b, *p),
_ => fit(&mut bp, &bez, 0.0, 1.0, tol, 0)?,
}
}
(bp.elements().len() > 1).then_some(bp)
}
/// The blossom of span `span` at `args` (de Boor's algorithm with one parameter per level).
fn blossom(degree: usize, knots: &[f64], ctrl: &[H], span: usize, args: &[f64]) -> Option<H> {
let mut d: Vec<H> = ctrl.get(span - degree..=span)?.to_vec();
for r in 1..=degree {
let u = *args.get(r - 1)?;
for j in (r..=degree).rev() {
let i = span - degree + j;
let (a, b) = (*knots.get(i)?, *knots.get(i + degree + 1 - r)?);
let alpha = if b - a > 1e-300 { (u - a) / (b - a) } else { 0.0 };
let prev = *d.get(j - 1)?;
let cur = d.get_mut(j)?;
*cur = prev.lerp(*cur, alpha);
}
}
d.get(degree).copied()
}
/// A (rational) Bézier of any degree at `t`, with its derivative.
fn eval(bez: &[H], t: f64) -> Option<(Point, Vec2)> {
let mut pts = bez.to_vec();
let n = pts.len().checked_sub(1)?;
// Down to the two points whose difference gives the tangent.
for level in (1..=n).rev() {
if level == 1 {
let (a, b) = (*pts.first()?, *pts.get(1)?);
let p = a.lerp(b, t);
let pt = p.point()?;
// d/dt (X/W) = (X'·W − X·W') / W², with X' = n·(b − a).
let k = n as f64;
let (dx, dy, dw) = (k * (b.x - a.x), k * (b.y - a.y), k * (b.w - a.w));
let w2 = p.w * p.w;
let tangent = Vec2::new((dx * p.w - p.x * dw) / w2, (dy * p.w - p.y * dw) / w2);
return Some((pt, tangent));
}
for i in 0..level {
let next = *pts.get(i + 1)?;
let cur = pts.get_mut(i)?;
*cur = cur.lerp(next, t);
}
}
// Degree 0: a point.
Some((pts.first()?.point()?, Vec2::ZERO))
}
/// Cubic pieces through the curve `bez` from `t0` to `t1`, each matching its ends and their
/// tangents, split until within `tol` of the curve.
fn fit(bp: &mut BezPath, bez: &[H], t0: f64, t1: f64, tol: f64, depth: u32) -> Option<()> {
let (p0, d0) = eval(bez, t0)?;
let (p3, d3) = eval(bez, t1)?;
let h = (t1 - t0) / 3.0;
let (c1, c2) = (p0 + d0 * h, p3 - d3 * h);
let cubic = kurbo::CubicBez::new(p0, c1, c2, p3);
let close = (1..8).all(|i| {
let s = f64::from(i) / 8.0;
eval(bez, t0 + (t1 - t0) * s).is_some_and(|(p, _)| kurbo::ParamCurve::eval(&cubic, s).distance(p) <= tol)
});
if close || depth >= MAX_DEPTH || bp.elements().len() >= MAX_ELEMENTS {
if [c1, c2].iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
bp.curve_to(c1, c2, p3);
} else {
bp.line_to(p3);
}
return Some(());
}
let mid = (t0 + t1) / 2.0;
fit(bp, bez, t0, mid, tol, depth + 1)?;
fit(bp, bez, mid, t1, tol, depth + 1)
}
/// A smooth curve through `pts` (a spline given by fit points only): Catmull-Rom tangents.
pub(crate) fn through(pts: &[Point], closed: bool) -> Option<BezPath> {
let n = pts.len();
let first = *pts.first()?;
if n < 2 {
return None;
}
let at = |i: isize| -> Point {
let len = n as isize;
let j = if closed { i.rem_euclid(len) } else { i.clamp(0, len - 1) };
pts.get(j as usize).copied().unwrap_or(first)
};
let mut bp = BezPath::new();
bp.move_to(first);
let segs = if closed { n } else { n - 1 };
for i in 0..segs as isize {
let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2));
bp.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2);
}
if closed {
bp.close_path();
}
Some(bp)
}
/// `bp`'s bounds, when it has any finite extent.
pub(crate) fn bounds(bp: &BezPath) -> Option<vectorcraft_geom::Rect> {
if bp.elements().is_empty() {
return None;
}
let r = bp.bounding_box();
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r)
}
#[cfg(test)]
mod tests {
use kurbo::PathEl;
use super::*;
/// The largest distance from `centre` minus `r` over points along `bp`.
fn radial_error(bp: &BezPath, centre: Point, r: f64) -> f64 {
let mut worst: f64 = 0.0;
for seg in bp.segments() {
for i in 0..=32 {
let p = kurbo::ParamCurve::eval(&seg, f64::from(i) / 32.0);
worst = worst.max((p.distance(centre) - r).abs());
}
}
worst
}
#[test]
fn a_bulge_of_one_is_a_half_circle_on_the_right_of_its_chord() {
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: 1.0 }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
let bp = polyline(&verts, false).unwrap();
assert!(radial_error(&bp, Point::new(5.0, 0.0), 5.0) < 1e-4);
// Counter-clockwise from (0, 0) to (10, 0) passes below the chord.
let b = bp.bounding_box();
assert!((b.y0 + 5.0).abs() < 1e-3 && b.y1.abs() < 1e-9, "{b:?}");
// A negative bulge turns the other way; a small one is a shallow arc.
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: -0.25 }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
let bp = polyline(&verts, false).unwrap();
// Sagitta = bulge · chord / 2; radius = chord (1 + bulge²) / (4 |bulge|).
let b = bp.bounding_box();
assert!((b.y1 - 1.25).abs() < 1e-4 && b.y0.abs() < 1e-9, "{b:?}");
let r = 10.0 * (1.0 + 0.0625) / 1.0;
assert!(radial_error(&bp, Point::new(5.0, 1.25 - r), r) < 1e-4);
}
#[test]
fn a_bulge_over_one_is_the_major_arc() {
let b = (3.0 * std::f64::consts::PI / 8.0).tan(); // a 270° arc
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: b }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
let bp = polyline(&verts, false).unwrap();
let r = 10.0 / (2.0 * (135f64).to_radians().sin());
// Counter-clockwise, so the centre and most of the circle lie right of the chord.
let centre = Point::new(5.0, -5.0);
assert!(radial_error(&bp, centre, r) < 1e-4);
assert!(bp.bounding_box().y0 < -12.0, "most of the circle lies below the chord");
}
#[test]
fn a_rational_quadratic_circle_stays_on_the_circle() {
// A whole circle of radius 100 as nine weighted control points (four quarter arcs).
let w = std::f64::consts::FRAC_1_SQRT_2;
let ctrl = [(1., 0.), (1., 1.), (0., 1.), (-1., 1.), (-1., 0.), (-1., -1.), (0., -1.), (1., -1.), (1., 0.)]
.map(|(x, y)| Point::new(x * 100.0, y * 100.0));
let weights = [1.0, w, 1.0, w, 1.0, w, 1.0, w, 1.0];
let knots = [0.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 4.0];
let bp = nurbs(2, &knots, &ctrl, Some(&weights)).unwrap();
let err = radial_error(&bp, Point::ZERO, 100.0);
assert!(err < 0.01, "error {err}");
}
#[test]
fn polynomial_splines_become_exact_beziers() {
// A clamped cubic with one interior knot: two cubic pieces meeting at its blossom.
let ctrl = [(0., 0.), (10., 20.), (30., 20.), (40., 0.), (50., -10.)].map(|(x, y)| Point::new(x, y));
let knots = [0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0];
let bp = nurbs(3, &knots, &ctrl, None).unwrap();
assert_eq!(bp.elements().iter().filter(|e| matches!(e, PathEl::CurveTo(..))).count(), 2);
assert!(matches!(bp.elements().first(), Some(PathEl::MoveTo(p)) if *p == Point::ZERO));
let end = match bp.elements().last() {
Some(PathEl::CurveTo(_, _, p)) => *p,
other => panic!("{other:?}"),
};
assert_eq!(end, Point::new(50.0, -10.0));
// Bad knot vectors are refused rather than read.
assert!(nurbs(3, &knots[1..], &ctrl, None).is_none());
assert!(nurbs(3, &[0.0, 0.0, 0.0, 0.0, 2.0, 1.0, 2.0, 2.0, 2.0], &ctrl, None).is_none());
assert!(nurbs(0, &knots, &ctrl, None).is_none());
}
#[test]
fn a_high_degree_spline_is_fitted_within_tolerance() {
// Degree 5 Bézier (one span) of a known polynomial: compare with direct evaluation.
let ctrl: Vec<Point> = (0..6).map(|i| Point::new(f64::from(i) * 10.0, if i % 2 == 0 { 0.0 } else { 30.0 })).collect();
let knots = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
let bp = nurbs(5, &knots, &ctrl, None).unwrap();
let hs: Vec<H> = ctrl.iter().map(|p| H { x: p.x, y: p.y, w: 1.0 }).collect();
for i in 0..=20 {
let (p, _) = eval(&hs, f64::from(i) / 20.0).unwrap();
let near = bp.segments().map(|s| kurbo::ParamCurveNearest::nearest(&s, p, 1e-9).distance_sq.sqrt()).fold(f64::MAX, f64::min);
assert!(near < 0.01, "error {near}");
}
}
#[test]
fn fit_points_make_a_smooth_curve_through_them() {
let pts = [Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)];
let bp = through(&pts, false).unwrap();
let ends: Vec<Point> = bp.elements().iter().filter_map(|e| if let PathEl::CurveTo(_, _, p) = e { Some(*p) } else { None }).collect();
assert_eq!(ends, [Point::new(10.0, 10.0), Point::new(20.0, 0.0)]);
}
}
+704
View File
@@ -0,0 +1,704 @@
//! Entities to drawing items: their geometry in world coordinates (drawing units, y up) and the
//! properties they take from their layer or block (resolved when the items become art).
use std::collections::BTreeMap;
use kurbo::{BezPath, PathEl};
use vectorcraft_doc::Justify;
use vectorcraft_geom::{Affine, Point, Vec2};
use super::curve::{self, Vertex};
use super::reader::{Groups, Pair, Record, groups, number};
use crate::aci::aci_rgb;
/// How far below the baseline the lowest descender reaches, as a fraction of the cap height.
const DESCENT: f64 = 0.3;
/// Baseline-to-baseline distance of multiline text at line spacing 1, in cap heights.
const LINE_SPACING: f64 = 5.0 / 3.0;
/// The most copies one array insert (MINSERT) makes.
const MAX_CELLS: i64 = 10_000;
/// Art farther than this from the origin (drawing units) is damaged data, left out.
const MAX_COORD: f64 = 1e12;
/// How far fit and aligned text may be squeezed or stretched to span their two points: at width
/// factor 1, the 1 to 10,000% horizontal scale the Character panel allows.
const SPAN_SCALE: (f64, f64) = (0.01, 100.0);
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
pub(crate) fn aci_paper(i: u64) -> [u8; 3] {
match u8::try_from(i) {
Ok(7) | Err(_) => [0; 3],
Ok(i) => aci_rgb(i),
}
}
/// The true colour (group 420, `0xRRGGBB`) of an entity or layer.
pub(crate) fn true_color(g: &[Pair<'_>]) -> Option<[u8; 3]> {
let v = g.int(420)?;
let [_, r, gr, b] = u32::try_from(v & 0xFF_FFFF).ok()?.to_be_bytes();
Some([r, gr, b])
}
/// An entity's colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Col {
ByLayer,
ByBlock,
Rgb([u8; 3]),
}
/// An entity's lineweight.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Lw {
ByLayer,
ByBlock,
Default,
/// Hundredths of a millimetre.
Hundredths(i32),
}
/// An entity's opacity.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Alpha {
ByLayer,
ByBlock,
Opacity(f32),
}
/// What an entity takes from its layer or block unless it sets it itself.
#[derive(Clone, Debug)]
pub(crate) struct Props {
pub layer: String,
pub color: Col,
pub lineweight: Lw,
/// The linetype name in upper case (`BYLAYER`, `BYBLOCK`, `CONTINUOUS` or a table entry).
pub linetype: String,
pub ltscale: f64,
pub alpha: Alpha,
}
impl Props {
fn read(g: &[Pair<'_>]) -> Self {
let color = match (true_color(g), g.int(62)) {
(Some(rgb), _) => Col::Rgb(rgb),
(None, Some(0)) => Col::ByBlock,
(None, None | Some(256..)) => Col::ByLayer,
(None, Some(i)) => Col::Rgb(aci_paper(i.unsigned_abs())),
};
let lineweight = match g.int(370) {
Some(-2) => Lw::ByBlock,
Some(-3) => Lw::Default,
Some(v @ 0..=211) => Lw::Hundredths(v as i32),
_ => Lw::ByLayer,
};
let alpha = match g.int(440) {
Some(v) if v & 0x0200_0000 != 0 => Alpha::Opacity((v & 0xFF) as f32 / 255.0),
Some(v) if v & 0x0100_0000 != 0 => Alpha::ByBlock,
_ => Alpha::ByLayer,
};
Self {
layer: g.name(8).unwrap_or("0").to_string(),
color,
lineweight,
linetype: g.name(6).unwrap_or("BYLAYER").to_uppercase(),
ltscale: g.num(48).filter(|v| *v > 0.0).unwrap_or(1.0),
alpha,
}
}
}
/// A line of text.
#[derive(Clone, Debug)]
pub(crate) struct TextItem {
pub text: String,
/// Cap height, in drawing units.
pub height: f64,
/// Text space (the first baseline's start at the origin, x along it, y up; drawing units) to
/// world coordinates.
pub xf: Affine,
/// Width factor (1: as designed).
pub width: f64,
pub justify: Justify,
/// The text style (upper case).
pub style: String,
/// A font family the text names itself (multiline text's font changes).
pub family: Option<String>,
/// Baseline-to-baseline distance (drawing units), for several lines.
pub leading: Option<f64>,
/// The distance aligned and fit text run along their baseline.
pub span: Option<Span>,
}
/// How text fills the distance between its two points (drawing units).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Span {
/// Stretched or squeezed across, its height kept.
Fit(f64),
/// Scaled as a whole, height included.
Aligned(f64),
}
impl Span {
/// The scale that makes text `natural` long run the span, within [`SPAN_SCALE`] (None when
/// either length is degenerate).
pub fn scale(self, natural: f64) -> Option<f64> {
let (Self::Fit(d) | Self::Aligned(d)) = self;
let k = d / natural;
(k.is_finite() && k > 0.0).then(|| k.clamp(SPAN_SCALE.0, SPAN_SCALE.1))
}
}
/// A block reference: one transform per copy (an array insert makes several), each from the
/// block's coordinates (less its base point) to world coordinates.
#[derive(Clone, Debug)]
pub(crate) struct InsertItem {
/// The block (upper case).
pub block: String,
pub cells: Vec<Affine>,
/// Its attributes' text.
pub attribs: Vec<Item>,
}
#[derive(Clone, Debug)]
pub(crate) enum Geom {
/// A path in world coordinates; `fill`: a filled area (hatch, solid), else a line of
/// `width` drawing units (0: the lineweight).
Path {
bp: BezPath,
fill: bool,
width: f64,
},
Text(TextItem),
Insert(InsertItem),
}
/// An entity as art: its geometry and its properties.
#[derive(Clone, Debug)]
pub(crate) struct Item {
pub props: Props,
pub geom: Geom,
}
/// Turns entity records into items, noting what it leaves out.
#[derive(Default)]
pub(crate) struct Converter {
/// Entity types left out, with how many.
pub skipped: BTreeMap<String, usize>,
pub warnings: Vec<String>,
}
const PATTERN_HATCH: &str = "pattern hatches come in as their boundaries";
const GRADIENT_HATCH: &str = "gradient hatches are filled with their first colour";
const MESHES: &str = "3D polygon meshes and polyface meshes are left out";
const BIG_ARRAY: &str = "array inserts are limited to 10,000 copies each";
const FAR_AWAY: &str = "entities with coordinates out of range are left out";
impl Converter {
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
fn skip(&mut self, kind: &str) {
*self.skipped.entry(kind.to_string()).or_default() += 1;
}
/// The items of `records` (an entity section or a block), in drawing order.
pub fn items(&mut self, records: &[Record<'_>]) -> Vec<Item> {
let mut out = Vec::new();
for group in groups(records) {
let Some(head) = group.first() else { continue };
// Invisible entities.
if head.g.int(60) == Some(1) {
continue;
}
match self.geom(head, group) {
Some(geom) if !in_range(&geom) => self.warn(FAR_AWAY),
Some(geom) => out.push(Item { props: Props::read(head.g), geom }),
None if is_known(head.kind) => {}
None => self.skip(head.kind),
}
}
out
}
fn geom(&mut self, head: &Record<'_>, group: &[Record<'_>]) -> Option<Geom> {
let g = head.g;
let path = |bp: BezPath| Some(Geom::Path { bp, fill: false, width: 0.0 });
match head.kind {
"LINE" => {
let mut bp = BezPath::new();
bp.move_to(g.point(10));
bp.line_to(g.point(11));
path(bp)
}
"CIRCLE" => path(transformed(curve::arc(g.point(10), g.num(40)?, 0.0, std::f64::consts::TAU)?, ocs(g))),
"ARC" => {
let (start, end) = (g.num_or(50, 0.0), g.num_or(51, 360.0));
let sweep = ccw_sweep(start, end);
path(transformed(curve::arc(g.point(10), g.num(40)?, start.to_radians(), sweep.to_radians())?, ocs(g)))
}
"ELLIPSE" => path(ellipse(g)?),
"LWPOLYLINE" => lwpolyline(g),
"POLYLINE" => self.polyline(g, group.get(1..).unwrap_or(&[])),
"SPLINE" => path(spline(g)?),
"SOLID" | "TRACE" => {
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
// The corners zigzag: the outline runs 1, 2, 4, 3.
Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs(g)), fill: true, width: 0.0 })
}
"3DFACE" => {
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
path(polygon(&[a, b, c, d]))
}
"HATCH" => self.hatch(g),
"TEXT" | "ATTRIB" => text(g, head.kind == "ATTRIB").map(Geom::Text),
"MTEXT" => mtext(g).map(Geom::Text),
"INSERT" => self.insert(g, group.get(1..).unwrap_or(&[])),
// A dimension draws the anonymous block of its lines, arrows and text.
"DIMENSION" => Some(Geom::Insert(InsertItem { block: g.name(2)?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })),
_ => None,
}
}
fn polyline(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
let flags = g.int_or(70, 0);
if flags & (16 | 64) != 0 {
self.warn(MESHES);
return None;
}
// Spline frame control points and polyface faces aren't on the line.
let verts: Vec<Vertex> = rest
.iter()
.filter(|r| r.kind == "VERTEX" && r.g.int_or(70, 0) & (16 | 128) == 0)
.map(|r| Vertex { p: r.g.point(10), bulge: r.g.num_or(42, 0.0) })
.collect();
let bp = curve::polyline(&verts, flags & 1 != 0)?;
// 3D polylines are in world coordinates; 2D ones in their plane's.
let bp = if flags & 8 != 0 { bp } else { transformed(bp, ocs(g)) };
Some(Geom::Path { bp, fill: false, width: g.num_or(40, 0.0).max(0.0) })
}
fn insert(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
let block = g.name(2)?.to_uppercase();
let (sx, sy) = (g.num_or(41, 1.0), g.num_or(42, 1.0));
if sx.abs() < 1e-12 || sy.abs() < 1e-12 {
return None;
}
let place = ocs(g) * Affine::translate(g.point(10).to_vec2()) * Affine::rotate(g.num_or(50, 0.0).to_radians());
let (cols, rows) = (g.int_or(70, 1).max(1), g.int_or(71, 1).max(1));
let (cs, rs) = (g.num_or(44, 0.0), g.num_or(45, 0.0));
let (cols, rows) = if cols.saturating_mul(rows) > MAX_CELLS {
self.warn(BIG_ARRAY);
(cols.min(MAX_CELLS), (MAX_CELLS / cols.min(MAX_CELLS)).max(1).min(rows))
} else {
(cols, rows)
};
let cells = (0..rows)
.flat_map(|r| (0..cols).map(move |c| (r, c)))
.map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy))
.collect();
let attribs = self.items(rest);
Some(Geom::Insert(InsertItem { block, cells, attribs }))
}
fn hatch(&mut self, g: &[Pair<'_>]) -> Option<Geom> {
let start = g.iter().position(|p| p.code == 91)?;
let count = g.get(start).and_then(|p| number(p.value)).filter(|n| *n >= 0.0)? as usize;
let mut c = Cursor { g, i: start + 1 };
let mut bp = BezPath::new();
for _ in 0..count {
let Some(path) = boundary(&mut c) else { break };
bp.extend(path);
}
if bp.elements().is_empty() {
return None;
}
let bp = transformed(bp, ocs(g));
let solid = g.int_or(70, 0) == 1;
if g.int_or(450, 0) == 1 {
self.warn(GRADIENT_HATCH);
} else if !solid {
self.warn(PATTERN_HATCH);
return Some(Geom::Path { bp, fill: false, width: 0.0 });
}
Some(Geom::Path { bp, fill: true, width: 0.0 })
}
}
/// Is the geometry within [`MAX_COORD`] of the origin?
fn in_range(g: &Geom) -> bool {
let near = |p: Point| p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD;
let origin = |m: &Affine| near(m.translation().to_point());
match g {
Geom::Path { bp, width, .. } => {
curve::bounds(bp).is_some_and(|b| near(Point::new(b.x0, b.y0)) && near(Point::new(b.x1, b.y1))) && *width <= MAX_COORD
}
Geom::Text(t) => origin(&t.xf) && t.height <= MAX_COORD,
Geom::Insert(i) => i.cells.iter().all(|m| origin(m) && m.as_coeffs().iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD)),
}
}
/// Entity types that are read but draw nothing themselves (or were reported when read).
fn is_known(kind: &str) -> bool {
matches!(
kind,
"LINE"
| "CIRCLE"
| "ARC"
| "ELLIPSE"
| "LWPOLYLINE"
| "POLYLINE"
| "SPLINE"
| "SOLID"
| "TRACE"
| "3DFACE"
| "HATCH"
| "TEXT"
| "ATTRIB"
| "MTEXT"
| "INSERT"
| "DIMENSION"
| "ATTDEF"
| "SEQEND"
| "VERTEX"
)
}
/// The sweep from `start` to `end` degrees counter-clockwise, in (0, 360].
fn ccw_sweep(start: f64, end: f64) -> f64 {
let s = (end - start).rem_euclid(360.0);
if s < 1e-9 { 360.0 } else { s }
}
fn transformed(mut bp: BezPath, m: Affine) -> BezPath {
if m != Affine::IDENTITY {
bp.apply_affine(m);
}
bp
}
fn polygon(pts: &[Point]) -> BezPath {
let mut bp = BezPath::new();
for (i, p) in pts.iter().enumerate() {
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
}
bp.close_path();
bp
}
/// The extrusion direction (210, 220, 230) of a planar entity, normalised.
fn extrusion(g: &[Pair<'_>]) -> [f64; 3] {
let n = [g.num_or(210, 0.0), g.num_or(220, 0.0), g.num_or(230, 1.0)];
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
if len > 1e-12 && len.is_finite() { n.map(|v| v / len) } else { [0.0, 0.0, 1.0] }
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn unit(v: [f64; 3]) -> [f64; 3] {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len > 1e-12 { v.map(|x| x / len) } else { v }
}
/// A planar entity's coordinate system seen from above: its x and y axes in world space by the
/// arbitrary axis algorithm (identity for drawings in the world's plane; extruded along −z it
/// mirrors x).
fn ocs(g: &[Pair<'_>]) -> Affine {
let n = extrusion(g);
if n[2] > 1.0 - 1e-12 {
return Affine::IDENTITY;
}
let ax = unit(if n[0].abs() < 1.0 / 64.0 && n[1].abs() < 1.0 / 64.0 { cross([0.0, 1.0, 0.0], n) } else { cross([0.0, 0.0, 1.0], n) });
let ay = unit(cross(n, ax));
Affine::new([ax[0], ax[1], ay[0], ay[1], 0.0, 0.0])
}
fn ellipse(g: &[Pair<'_>]) -> Option<BezPath> {
let centre = g.point(10);
let major = g.point(11).to_vec2();
let ratio = g.num_or(40, 1.0);
if ratio <= 0.0 || major.hypot() < 1e-12 {
return None;
}
// The minor axis is the major one turned a quarter about the extrusion direction.
let n = extrusion(g);
let m = cross(n, [major.x, major.y, 0.0]);
let minor = Vec2::new(m[0], m[1]) * ratio;
let (start, end) = (g.num_or(41, 0.0), g.num_or(42, std::f64::consts::TAU));
let sweep = ccw_sweep(start.to_degrees(), end.to_degrees()).to_radians();
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start, sweep)
}
fn lwpolyline(g: &[Pair<'_>]) -> Option<Geom> {
let mut verts: Vec<Vertex> = Vec::new();
let (mut widths, mut uniform) = (None, true);
for p in g {
match p.code {
10 => verts.push(Vertex { p: Point::new(number(p.value)?, 0.0), bulge: 0.0 }),
20 => verts.last_mut()?.p.y = number(p.value)?,
42 => verts.last_mut()?.bulge = number(p.value).unwrap_or(0.0),
40 | 41 => {
let w = number(p.value).unwrap_or(0.0);
uniform &= widths.is_none_or(|x: f64| (x - w).abs() < 1e-12);
widths = Some(w);
}
_ => {}
}
}
let bp = transformed(curve::polyline(&verts, g.int_or(70, 0) & 1 != 0)?, ocs(g));
let width = g.num(43).filter(|w| *w > 0.0).or(widths.filter(|_| uniform)).unwrap_or(0.0).max(0.0);
Some(Geom::Path { bp, fill: false, width })
}
/// The points at `code` (x) and `code + 10` (y), in order.
fn points(g: &[Pair<'_>], code: i32) -> Option<Vec<Point>> {
let mut out: Vec<Point> = Vec::new();
for p in g {
if p.code == code {
out.push(Point::new(number(p.value)?, 0.0));
} else if p.code == code + 10 {
out.last_mut()?.y = number(p.value)?;
}
}
Some(out)
}
fn spline(g: &[Pair<'_>]) -> Option<BezPath> {
let flags = g.int_or(70, 0);
let ctrl = points(g, 10)?;
let degree = usize::try_from(g.int_or(71, 3)).ok()?;
let knots: Vec<f64> = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::<Option<_>>()?;
let weights: Vec<f64> = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect();
let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice());
curve::nurbs(degree, &knots, &ctrl, weights).or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
}
/// Reads a hatch's boundary groups in order.
struct Cursor<'g, 'a> {
g: &'g [Pair<'a>],
i: usize,
}
impl Cursor<'_, '_> {
/// The next group's value when it has `code` (consumed).
fn opt(&mut self, code: i32) -> Option<&str> {
let p = self.g.get(self.i).filter(|p| p.code == code)?;
self.i += 1;
Some(p.value)
}
fn num(&mut self, code: i32) -> Option<f64> {
self.opt(code).and_then(number)
}
fn count(&mut self, code: i32) -> Option<usize> {
self.num(code).filter(|n| *n >= 0.0 && *n <= 1e7).map(|n| n as usize)
}
fn point(&mut self, code: i32) -> Option<Point> {
Some(Point::new(self.num(code)?, self.num(code + 10)?))
}
}
/// One hatch boundary loop, closed.
fn boundary(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
let flags = c.num(92)? as i64;
let mut bp = BezPath::new();
if flags & 2 != 0 {
let bulges = c.num(72).unwrap_or(0.0) != 0.0;
let _closed = c.opt(73);
let n = c.count(93)?;
let mut verts = Vec::with_capacity(n.min(4096));
for _ in 0..n {
let p = c.point(10)?;
let bulge = if bulges { c.num(42).unwrap_or(0.0) } else { 0.0 };
verts.push(Vertex { p, bulge });
}
bp = curve::polyline(&verts, true)?;
} else {
let n = c.count(93)?;
for _ in 0..n {
let edge = edge(c)?;
append(&mut bp, &edge);
}
if bp.elements().is_empty() {
return None;
}
bp.close_path();
}
// The boundary objects it was made from.
if let Some(n) = c.count(97) {
for _ in 0..n {
c.opt(330);
}
}
Some(bp)
}
/// One edge of a hatch boundary.
fn edge(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
match c.num(72)? as i64 {
1 => {
let mut bp = BezPath::new();
bp.move_to(c.point(10)?);
bp.line_to(c.point(11)?);
Some(bp)
}
2 => {
let (centre, r, start, end) = (c.point(10)?, c.num(40)?, c.num(50)?, c.num(51)?);
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
let sweep = ccw_sweep(start, end);
// Clockwise arcs have their angles measured clockwise.
let (start, sweep) = if ccw { (start, sweep) } else { (-start, -sweep) };
curve::arc(centre, r, start.to_radians(), sweep.to_radians())
}
3 => {
let (centre, major, ratio, start, end) = (c.point(10)?, c.point(11)?.to_vec2(), c.num(40)?, c.num(50)?, c.num(51)?);
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
let minor = Vec2::new(-major.y, major.x) * ratio * if ccw { 1.0 } else { -1.0 };
let sweep = ccw_sweep(start, end).to_radians();
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start.to_radians(), sweep)
}
4 => {
let degree = c.count(94)?;
let rational = c.num(73).unwrap_or(0.0) != 0.0;
let _periodic = c.opt(74);
let (nk, nc) = (c.count(95)?, c.count(96)?);
let knots: Vec<f64> = (0..nk).map(|_| c.num(40)).collect::<Option<_>>()?;
let mut ctrl = Vec::with_capacity(nc.min(4096));
let mut weights = Vec::with_capacity(nc.min(4096));
for _ in 0..nc {
ctrl.push(c.point(10)?);
weights.push(c.num(42).unwrap_or(1.0));
}
// Fit data (DXF 2010 and later): a count, then that many points. Older files have none,
// and the 97 that may follow is the boundary's count of source objects (then their
// handles), so it is only the fit count when it is 0 or points follow.
let fit_count = match (c.g.get(c.i), c.g.get(c.i + 1)) {
(Some(count), next) if count.code == 97 => number(count.value) == Some(0.0) || next.is_some_and(|p| p.code == 11),
_ => false,
};
let fit: Vec<Point> = match fit_count.then(|| c.count(97)).flatten() {
Some(n) => (0..n).map(|_| c.point(11)).collect::<Option<_>>()?,
None => vec![],
};
// Start and end tangents.
for code in [12, 13] {
c.point(code);
}
curve::nurbs(degree, &knots, &ctrl, rational.then_some(weights.as_slice())).or_else(|| curve::through(&fit, false))
}
_ => None,
}
}
/// `seg` continuing `bp`: joined by a line when it starts elsewhere.
fn append(bp: &mut BezPath, seg: &BezPath) {
for el in seg.elements() {
match *el {
PathEl::MoveTo(p) => {
let here = bp.elements().last().and_then(|e| e.end_point());
match here {
None => bp.move_to(p),
Some(q) if q.distance(p) > 1e-9 => bp.line_to(p),
Some(_) => {}
}
}
PathEl::ClosePath => {}
other => bp.push(other),
}
}
}
/// The baseline offset of vertical alignment `v` (0 baseline, 1 bottom, 2 middle, 3 top).
fn baseline(v: i64, height: f64) -> f64 {
match v {
1 => height * DESCENT,
2 => -height / 2.0,
3 => -height,
_ => 0.0,
}
}
fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
if attrib && g.int_or(70, 0) & 1 != 0 {
return None;
}
let s = super::text::plain(g.raw(1)?);
if s.trim().is_empty() {
return None;
}
let height = g.num(40).filter(|h| *h > 0.0)?;
let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0));
let (p1, p2) = (g.point(10), g.point_opt(11));
let mut angle = g.num_or(50, 0.0).to_radians();
let mut span = None;
// Aligned and fit text run from the first point to the second.
let anchor = match (h, p2) {
(3 | 5, Some(p)) => {
angle = (p - p1).atan2();
let d = p1.distance(p);
span = (d > 1e-12).then_some(if h == 3 { Span::Aligned(d) } else { Span::Fit(d) });
p1
}
(0, _) if v == 0 => p1,
(_, Some(p)) => p,
_ => p1,
};
let justify = match h {
1 | 4 => Justify::Center,
2 => Justify::Right,
_ => Justify::Left,
};
let v = if h == 4 { 2 } else { v };
let flags = g.int_or(71, 0);
let mirror = Affine::scale_non_uniform(if flags & 2 != 0 { -1.0 } else { 1.0 }, if flags & 4 != 0 { -1.0 } else { 1.0 });
let xf = ocs(g) * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height)));
Some(TextItem {
text: s,
height,
xf,
width: g.num(41).filter(|w| *w > 0.0).unwrap_or(1.0),
justify,
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
family: None,
leading: None,
span,
})
}
fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
// The text comes in 250-character chunks (3) before the last (1).
let raw: String = g.iter().filter(|p| p.code == 3).map(|p| p.value).chain(g.raw(1)).collect();
let (s, family) = super::text::mtext(&raw);
if s.trim().is_empty() {
return None;
}
let height = g.num(40).filter(|h| *h > 0.0)?;
let leading = height * LINE_SPACING * g.num(44).filter(|f| *f > 0.0).unwrap_or(1.0);
let lines = s.lines().count().max(1) as f64;
let attach = g.int_or(71, 1).clamp(1, 9) - 1;
let justify = [Justify::Left, Justify::Center, Justify::Right].get((attach % 3) as usize).copied().unwrap_or_default();
// The first baseline below the attachment point: under the top, around the middle, above
// the bottom of the lines.
let total = height + (lines - 1.0) * leading;
let first = match attach / 3 {
0 => -height,
1 => total / 2.0 - height,
_ => (lines - 1.0) * leading,
};
let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12);
let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2());
let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first));
Some(TextItem {
text: s,
height,
xf,
width: 1.0,
justify,
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
family,
leading: Some(leading),
span: None,
})
}
+444
View File
@@ -0,0 +1,444 @@
//! Reading an ASCII DXF file: its text, the group-code pairs, and what the import uses of its
//! sections (header variables; the layer, linetype, text style and block record tables; the
//! blocks; the entities; the layouts).
use std::borrow::Cow;
use std::collections::HashMap;
use vectorcraft_geom::Point;
/// The most group-code pairs a drawing may hold (about 300 MB of DXF).
const MAX_PAIRS: usize = 20_000_000;
/// The first bytes of a binary DXF file (its format's signature).
pub const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\x00";
/// One group: its code and its value as written.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Pair<'a> {
pub code: i32,
pub value: &'a str,
}
/// Values of a run of pairs by group code.
pub(crate) trait Groups<'a> {
/// The value of the first `code`, as written.
fn raw(&self, code: i32) -> Option<&'a str>;
/// The first `code` as a name (trimmed, never empty).
fn name(&self, code: i32) -> Option<&'a str> {
self.raw(code).map(str::trim).filter(|s| !s.is_empty())
}
/// The first `code` as a finite number.
fn num(&self, code: i32) -> Option<f64> {
self.raw(code).and_then(number)
}
fn num_or(&self, code: i32, default: f64) -> f64 {
self.num(code).unwrap_or(default)
}
/// The first `code` as a whole number.
fn int(&self, code: i32) -> Option<i64> {
self.raw(code).and_then(integer)
}
fn int_or(&self, code: i32, default: i64) -> i64 {
self.int(code).unwrap_or(default)
}
/// The point at `code` (x) and `code + 10` (y); 0 for what is missing.
fn point(&self, code: i32) -> Point {
Point::new(self.num_or(code, 0.0), self.num_or(code + 10, 0.0))
}
/// [`Groups::point`] when its x is given.
fn point_opt(&self, code: i32) -> Option<Point> {
self.num(code).map(|x| Point::new(x, self.num_or(code + 10, 0.0)))
}
}
impl<'a> Groups<'a> for [Pair<'a>] {
fn raw(&self, code: i32) -> Option<&'a str> {
self.iter().find(|p| p.code == code).map(|p| p.value)
}
}
/// A finite number (`1e999`, `NaN` and junk are none).
pub(crate) fn number(s: &str) -> Option<f64> {
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
}
/// A whole number; some writers put integers as `1.0`.
pub(crate) fn integer(s: &str) -> Option<i64> {
let s = s.trim();
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15).map(|v| v as i64))
}
/// One object: its type (the value of its `0` group) and the groups up to the next object.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Record<'a> {
pub kind: &'a str,
pub g: &'a [Pair<'a>],
}
/// The text of a DXF file: UTF-8 (DXF 2007 and later), else the Windows code page older versions
/// write (a byte order mark is dropped).
pub(crate) fn decode(bytes: &[u8]) -> Cow<'_, str> {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
match std::str::from_utf8(bytes) {
Ok(s) => Cow::Borrowed(s),
Err(_) => Cow::Owned(bytes.iter().map(|b| windows_1252(*b)).collect()),
}
}
/// A byte of the Windows Western code page.
fn windows_1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
'˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
];
match b {
0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
_ => char::from(b),
}
}
/// The group-code pairs of `text`, up to `0 EOF`. A line that isn't a group code ends the
/// reading: what came before is kept and the second value says where the file is damaged.
pub(crate) fn pairs(text: &str) -> Result<(Vec<Pair<'_>>, Option<String>), String> {
let mut out = Vec::new();
let mut lines = text.lines().enumerate();
let mut damaged = None;
while let Some((n, code)) = lines.next() {
let c = code.trim();
if c.is_empty() && out.is_empty() {
continue;
}
let Ok(code) = c.parse::<i32>() else {
damaged = Some(format!("the file is damaged at line {}: what follows is left out", n + 1));
break;
};
let value = lines.next().map_or("", |(_, v)| v);
if code == 0 && value.trim() == "EOF" {
break;
}
if out.len() >= MAX_PAIRS {
return Err("the drawing is too large to open".into());
}
out.push(Pair { code, value });
}
if out.is_empty() {
return Err("not a DXF drawing".into());
}
Ok((out, damaged))
}
/// The objects in `pairs`: each from a `0` group up to the next.
fn records<'a>(pairs: &'a [Pair<'a>]) -> Vec<Record<'a>> {
let mut out = Vec::new();
let mut start: Option<usize> = None;
for (i, p) in pairs.iter().enumerate() {
if p.code == 0 {
if let Some(s) = start {
out.extend(record(pairs, s, i));
}
start = Some(i);
}
}
if let Some(s) = start {
out.extend(record(pairs, s, pairs.len()));
}
out
}
fn record<'a>(pairs: &'a [Pair<'a>], start: usize, end: usize) -> Option<Record<'a>> {
let kind = pairs.get(start)?.value.trim();
Some(Record { kind, g: pairs.get(start + 1..end)? })
}
/// The header variables the import reads.
#[derive(Clone, Debug, Default)]
pub(crate) struct Header {
/// `$ACADVER` (`AC1032`…).
pub acadver: String,
/// `$INSUNITS`: the drawing unit (0: unitless).
pub insunits: i64,
/// `$MEASUREMENT`: 0 imperial, 1 metric.
pub measurement: Option<i64>,
/// `$LTSCALE`: the global linetype scale.
pub ltscale: f64,
/// `$LWDEFAULT`: the default lineweight (hundredths of a millimetre).
pub lwdefault: i32,
}
/// A layer of the LAYER table.
#[derive(Clone, Debug)]
pub(crate) struct LayerDef {
pub name: String,
pub rgb: [u8; 3],
/// Switched off (a negative colour) or frozen.
pub hidden: bool,
pub locked: bool,
pub plot: bool,
/// Its linetype (upper case).
pub linetype: String,
/// Hundredths of a millimetre; negative: the default.
pub lineweight: i32,
}
/// A block definition.
#[derive(Clone, Debug)]
pub(crate) struct BlockDef<'a> {
pub name: String,
pub base: Point,
pub records: Vec<Record<'a>>,
/// A reference to another drawing (its art isn't in this file).
pub xref: bool,
}
/// A layout: model space, or a paper layout with the block holding its art.
#[derive(Clone, Debug)]
pub(crate) struct LayoutDef {
pub name: String,
/// The block of a paper layout (upper case); `None` for model space.
pub block: Option<String>,
}
/// The name of model space.
pub(crate) const MODEL: &str = "Model";
const MODEL_BLOCK: &str = "*MODEL_SPACE";
const PAPER_BLOCK: &str = "*PAPER_SPACE";
/// A drawing as the import reads it.
#[derive(Debug, Default)]
pub(crate) struct Drawing<'a> {
pub header: Header,
pub layers: Vec<LayerDef>,
/// Index into `layers` by upper-case name.
layer_index: HashMap<String, usize>,
/// Linetype patterns by upper-case name: dash (positive), gap (negative), dot (0) lengths.
pub linetypes: HashMap<String, Vec<f64>>,
/// The font family of each text style (upper-case name; empty: a CAD font).
pub styles: HashMap<String, String>,
/// Blocks by upper-case name.
pub blocks: HashMap<String, BlockDef<'a>>,
/// The ENTITIES section.
pub entities: Vec<Record<'a>>,
/// Model space first, then the paper layouts in tab order.
pub layouts: Vec<LayoutDef>,
/// How many sections the file has (none: not a drawing).
pub sections: usize,
}
impl<'a> Drawing<'a> {
/// Read the sections of `pairs`.
pub fn read(pairs: &'a [Pair<'a>]) -> Self {
let mut d = Drawing { header: Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }, ..Drawing::default() };
let all = records(pairs);
let mut section: Option<&str> = None;
let (mut tables, mut blocks, mut objects) = (vec![], vec![], vec![]);
for r in all {
match (r.kind, section) {
("SECTION", _) => {
d.sections += 1;
section = r.g.name(2);
if section == Some("HEADER") {
d.header = header(r.g);
}
}
("ENDSEC", _) => section = None,
(_, Some("TABLES")) => tables.push(r),
(_, Some("BLOCKS")) => blocks.push(r),
(_, Some("ENTITIES")) => d.entities.push(r),
(_, Some("OBJECTS")) => objects.push(r),
_ => {}
}
}
let mut block_records = HashMap::new();
let mut table = "";
for r in &tables {
match r.kind {
"TABLE" => table = r.g.name(2).unwrap_or(""),
"LAYER" if table == "LAYER" => {
if let Some(l) = layer(r.g) {
d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len());
d.layers.push(l);
}
}
"LTYPE" if table == "LTYPE" => {
if let Some(name) = r.g.name(2) {
let pattern: Vec<f64> = r.g.iter().filter(|p| p.code == 49).filter_map(|p| number(p.value)).collect();
d.linetypes.insert(name.to_uppercase(), pattern);
}
}
"STYLE" if table == "STYLE" => {
if let Some(name) = r.g.name(2) {
d.styles.insert(name.to_uppercase(), font_family(r.g));
}
}
"BLOCK_RECORD" if table == "BLOCK_RECORD" => {
if let (Some(h), Some(name)) = (r.g.name(5), r.g.name(2)) {
block_records.insert(h.to_uppercase(), name.to_uppercase());
}
}
_ => {}
}
}
d.read_blocks(&blocks, &mut block_records);
d.layouts = layouts(&objects, &block_records, &d.entities);
d
}
fn read_blocks(&mut self, records: &[Record<'a>], block_records: &mut HashMap<String, String>) {
let mut current: Option<BlockDef<'a>> = None;
for r in records {
match r.kind {
"BLOCK" => {
let name = r.g.name(2).or_else(|| r.g.name(3)).unwrap_or("").to_string();
// R13+ blocks name their block record: layouts find their block through it.
if let Some(owner) = r.g.name(330) {
block_records.entry(owner.to_uppercase()).or_insert_with(|| name.to_uppercase());
}
let xref = r.g.int_or(70, 0) & 4 != 0;
current = Some(BlockDef { name, base: r.g.point(10), records: vec![], xref });
}
"ENDBLK" => {
if let Some(b) = current.take().filter(|b| !b.name.is_empty()) {
self.blocks.insert(b.name.to_uppercase(), b);
}
}
_ => {
if let Some(b) = &mut current {
b.records.push(*r);
}
}
}
}
}
/// The layout named `name` (any case; `model` is model space).
pub fn layout(&self, name: &str) -> Option<&LayoutDef> {
self.layouts.iter().find(|l| l.name.eq_ignore_ascii_case(name.trim()))
}
/// The entity records of `layout`.
pub fn layout_records(&self, layout: &LayoutDef) -> Vec<Record<'a>> {
let block = |name: &str| self.blocks.get(name).map(|b| b.records.clone()).unwrap_or_default();
// The active paper layout keeps its art in the ENTITIES section, flagged paper space.
let (block_name, paper) = match &layout.block {
None => (MODEL_BLOCK, false),
Some(b) if b == PAPER_BLOCK => (PAPER_BLOCK, true),
Some(b) => return block(b),
};
let mut out: Vec<Record<'a>> = groups(&self.entities).filter(|g| in_paper_space(g) == paper).flatten().copied().collect();
out.extend(block(block_name));
out
}
/// The layer named `name` (any case).
pub fn layer(&self, name: &str) -> Option<&LayerDef> {
self.layer_index.get(&name.to_uppercase()).and_then(|i| self.layers.get(*i))
}
}
/// Is an entity (its first record) in paper space?
fn in_paper_space(group: &[Record<'_>]) -> bool {
group.first().is_some_and(|r| r.g.int(67) == Some(1))
}
/// The entities of `records`, each with the records that belong to it (a polyline's vertices,
/// an insert's attributes, and the SEQEND closing them).
pub(crate) fn groups<'r, 'a>(records: &'r [Record<'a>]) -> impl Iterator<Item = &'r [Record<'a>]> {
let mut i = 0;
std::iter::from_fn(move || {
let start = i;
let head = records.get(start)?;
i += 1;
if matches!(head.kind, "POLYLINE" | "INSERT") {
while let Some(r) = records.get(i) {
match r.kind {
"VERTEX" | "ATTRIB" => i += 1,
"SEQEND" => {
i += 1;
break;
}
_ => break,
}
}
}
records.get(start..i)
})
}
fn header(g: &[Pair<'_>]) -> Header {
let mut h = Header { ltscale: 1.0, lwdefault: 25, ..Header::default() };
let mut var = "";
for p in g {
if p.code == 9 {
var = p.value.trim();
continue;
}
match (var, p.code) {
("$ACADVER", 1) => h.acadver = p.value.trim().to_string(),
("$INSUNITS", 70) => h.insunits = integer(p.value).unwrap_or(0),
("$MEASUREMENT", 70) => h.measurement = integer(p.value),
("$LTSCALE", 40) => h.ltscale = number(p.value).filter(|v| *v > 0.0).unwrap_or(1.0),
("$LWDEFAULT", 370) => h.lwdefault = integer(p.value).and_then(|v| i32::try_from(v).ok()).filter(|v| *v >= 0).unwrap_or(25),
_ => {}
}
}
h
}
fn layer(g: &[Pair<'_>]) -> Option<LayerDef> {
let name = g.name(2)?.to_string();
let aci = g.int_or(62, 7);
let flags = g.int_or(70, 0);
let rgb = super::entity::true_color(g).unwrap_or_else(|| super::entity::aci_paper(aci.unsigned_abs()));
Some(LayerDef {
// CAD apps never plot this layer of dimension points.
plot: g.int_or(290, 1) != 0 && !name.eq_ignore_ascii_case("Defpoints"),
name,
rgb,
hidden: aci < 0 || flags & 1 != 0,
locked: flags & 4 != 0,
linetype: g.name(6).unwrap_or("CONTINUOUS").to_uppercase(),
lineweight: g.int(370).and_then(|v| i32::try_from(v).ok()).unwrap_or(-3),
})
}
/// A text style's font family: the one 2000-and-later files name, else the font file's name
/// (TrueType); CAD fonts (`.shx`) have none.
fn font_family(g: &[Pair<'_>]) -> String {
if let Some(i) = g.iter().position(|p| p.code == 1001 && p.value.trim() == "ACAD")
&& let Some(f) = g.get(i + 1..).and_then(|rest| rest.name(1000))
{
return f.to_string();
}
let file = g.name(3).unwrap_or("");
let path = std::path::Path::new(file);
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
match ext.as_str() {
"ttf" | "otf" | "ttc" => path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
_ => String::new(),
}
}
/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout
/// objects but with paper-space art has one paper layout.
fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, entities: &[Record<'_>]) -> Vec<LayoutDef> {
let mut paper: Vec<(i64, LayoutDef)> = vec![];
for r in objects.iter().filter(|r| r.kind == "LAYOUT") {
// The layout's own groups follow its subclass marker.
let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]);
let Some(name) = own.name(1) else { continue };
let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase())).cloned();
if name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) {
continue;
}
let Some(block) = block else { continue };
if !paper.iter().any(|(_, l)| l.name.eq_ignore_ascii_case(name)) {
paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block) }));
}
}
paper.sort_by_key(|(order, _)| *order);
if paper.is_empty() && groups(entities).any(in_paper_space) {
paper.push((1, LayoutDef { name: "Layout1".into(), block: Some(PAPER_BLOCK.into()) }));
}
std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect()
}
+904
View File
@@ -0,0 +1,904 @@
//! DXF import: a fixture per entity, bulge and spline accuracy, blocks as symbols, layers,
//! colours, lineweights, layouts, the options, damaged and binary files, and drawings the export
//! wrote read back.
use std::sync::Arc;
use kurbo::ParamCurve;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Justify, Node, NodeKind, TextObject, Unit};
use vectorcraft_geom::{Affine, FillRule, Point, Rect, shapes};
use super::*;
/// A DXF file from `(code, value)` pairs written one per line.
fn file(pairs: &[(i32, &str)]) -> Vec<u8> {
let mut s = String::new();
for (c, v) in pairs {
s.push_str(&format!("{c:>3}\n{v}\n"));
}
s.push_str(" 0\nEOF\n");
s.into_bytes()
}
/// A drawing with `header` variables, `tables`, `blocks` and `entities` (each a run of pairs).
fn drawing(header: &[(i32, &str)], tables: &[(i32, &str)], blocks: &[(i32, &str)], entities: &[(i32, &str)]) -> Vec<u8> {
let mut p: Vec<(i32, &str)> = vec![(0, "SECTION"), (2, "HEADER")];
p.extend_from_slice(header);
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "TABLES")]);
p.extend_from_slice(tables);
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "BLOCKS")]);
p.extend_from_slice(blocks);
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "ENTITIES")]);
p.extend_from_slice(entities);
p.push((0, "ENDSEC"));
file(&p)
}
fn entities(e: &[(i32, &str)]) -> Vec<u8> {
drawing(&[], &[], &[], e)
}
/// Points at 1:1, not centred: drawing (x, y) lands at document (x, artboard height − y).
fn points() -> ImportOptions {
ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() }
}
fn open(bytes: &[u8], o: &ImportOptions) -> Imported {
import(bytes, o).unwrap()
}
/// Every leaf object, in paint order.
fn leaves(doc: &Document) -> Vec<Node> {
let mut out = vec![];
doc.walk(|n| {
if n.children().is_none() {
out.push(n.clone());
}
});
out
}
/// The artboard's height (document y = height − drawing y).
fn height(doc: &Document) -> f64 {
doc.artboards[0].rect.height()
}
fn path_points(n: &Node) -> Vec<Point> {
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path: {:?}", n.kind) };
path.to_bezpath().segments().flat_map(|s| (0..=16).map(move |i| s.eval(f64::from(i) / 16.0))).collect()
}
fn bounds(n: &Node) -> Rect {
n.geometric_bounds().unwrap()
}
fn close(a: Rect, b: Rect, tol: f64) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol)
}
fn rgba(p: Option<&Paint>) -> [u8; 4] {
match p {
Some(Paint::Solid { color, .. }) => color.to_rgba8(1.0),
other => panic!("not a solid paint: {other:?}"),
}
}
fn stroke_rgb(n: &Node) -> [u8; 4] {
rgba(n.appearance.stroke().map(|s| &s.paint))
}
fn fill_rgb(n: &Node) -> [u8; 4] {
rgba(n.appearance.fill().map(|f| &f.paint))
}
fn text_of(n: &Node) -> &TextObject {
let NodeKind::Text(t) = &n.kind else { panic!("not text: {:?}", n.kind) };
t
}
const LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "10"), (20, "20"), (11, "110"), (21, "70")];
#[test]
fn a_line_comes_in_at_the_drawings_unit_and_origin() {
let doc = open(&entities(LINE), &points()).document;
let [line] = &leaves(&doc)[..] else { panic!() };
let board = doc.artboards[0].rect;
assert_eq!((board.width(), board.height()), (100.0, 50.0), "the artboard is the art's size");
// Not centred: the origin sits on the artboard's bottom-left corner.
let pts = path_points(line);
assert!(pts.first().unwrap().distance(Point::new(10.0, 50.0 - 20.0)) < 1e-9, "{pts:?}");
assert!(pts.last().unwrap().distance(Point::new(110.0, 50.0 - 70.0)) < 1e-9);
assert_eq!(stroke_rgb(line), [0, 0, 0, 255], "colour 7 is black on paper");
let w = line.appearance.stroke().unwrap().width;
assert!((w - 0.25 * 72.0 / 25.4).abs() < 1e-9, "the default lineweight is 0.25 mm: {w}");
assert!(line.appearance.fill().is_none_or(|f| f.paint.is_none()));
}
#[test]
fn units_choose_the_default_scale() {
// Millimetres at 1:1: 100 units are 100 mm.
let mm = drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], LINE);
let doc = open(&mm, &ImportOptions::default()).document;
assert!((doc.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6);
assert_eq!(doc.units, Unit::Millimeters);
// Inches; microns (1 mm = 1000 units); unitless imperial drawings read inches.
let inch = open(&drawing(&[(9, "$INSUNITS"), (70, "1")], &[], &[], LINE), &ImportOptions::default()).document;
assert!((inch.artboards[0].rect.width() - 7200.0).abs() < 1e-6);
let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap();
assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns"));
assert!((um.scale - 1000.0).abs() < 1e-6);
let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap();
assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0));
// A ratio: 1 cm = 10 units is 1:1 in millimetres again.
let o = ImportOptions { unit: Some(Unit::Centimeters), scale: Some(10.0), ..ImportOptions::default() };
let cm = open(&mm, &o).document;
assert!((cm.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6);
assert_eq!(cm.units, Unit::Centimeters);
}
#[test]
fn circles_arcs_and_ellipses() {
let e = [
(0, "CIRCLE"),
(10, "50"),
(20, "50"),
(40, "25"),
(0, "ARC"),
(10, "200"),
(20, "50"),
(40, "20"),
(50, "0"),
(51, "90"),
(0, "ELLIPSE"),
(10, "400"),
(20, "50"),
(11, "40"),
(21, "0"),
(40, "0.5"),
(41, "0"),
(42, "6.283185307179586"),
];
let doc = open(&entities(&e), &points()).document;
let h = height(&doc);
let flip = |r: Rect| Rect::new(r.x0, h - r.y1, r.x1, h - r.y0);
let [circle, arc, ellipse] = &leaves(&doc)[..] else { panic!() };
assert!(close(bounds(circle), flip(Rect::new(25.0, 25.0, 75.0, 75.0)), 1e-3), "{:?}", bounds(circle));
for p in path_points(circle) {
assert!((p.distance(Point::new(50.0, h - 50.0)) - 25.0).abs() < 1e-4);
}
// A quarter arc counter-clockwise from 0° to 90°.
assert!(close(bounds(arc), flip(Rect::new(200.0, 50.0, 220.0, 70.0)), 1e-3), "{:?}", bounds(arc));
assert!(close(bounds(ellipse), flip(Rect::new(360.0, 30.0, 440.0, 70.0)), 1e-3), "{:?}", bounds(ellipse));
let NodeKind::Path { path, .. } = &circle.kind else { panic!() };
assert!(path.is_closed());
}
#[test]
fn polyline_bulges_are_exact_arcs() {
// A slot: two straight sides and two half circles (bulge 1) of radius 10.
let e = [
(0, "LWPOLYLINE"),
(90, "4"),
(70, "1"),
(10, "0"),
(20, "0"),
(10, "100"),
(20, "0"),
(42, "1"),
(10, "100"),
(20, "20"),
(10, "0"),
(20, "20"),
(42, "1"),
];
let doc = open(&entities(&e), &points()).document;
let [slot] = &leaves(&doc)[..] else { panic!() };
let h = height(&doc);
// The arcs bulge outward, so the art is 120 wide.
assert!(close(bounds(slot), Rect::new(-10.0, h - 20.0, 110.0, h), 1e-4), "{:?}", bounds(slot));
for p in path_points(slot).into_iter().filter(|p| p.x > 100.0) {
let err = (p.distance(Point::new(100.0, h - 10.0)) - 10.0).abs();
assert!(err < 1e-4, "bulge error {err}");
}
// Old-style polylines read their vertices and bulges too.
let e = [
(0, "POLYLINE"),
(66, "1"),
(70, "0"),
(0, "VERTEX"),
(10, "0"),
(20, "0"),
(42, "-1"),
(0, "VERTEX"),
(10, "20"),
(20, "0"),
(0, "SEQEND"),
(0, "LINE"),
(10, "0"),
(20, "0"),
(11, "0"),
(21, "-30"),
];
let doc = open(&entities(&e), &points()).document;
let [arc, _] = &leaves(&doc)[..] else { panic!() };
// Clockwise from (0, 0) to (20, 0): over the chord, 10 high.
let b = bounds(arc);
assert!((b.width() - 20.0).abs() < 1e-4 && (b.height() - 10.0).abs() < 1e-4, "{b:?}");
assert!((b.y1 - height(&doc)).abs() < 1e-4, "the arc's chord is at y 0: {b:?}");
}
#[test]
fn nurbs_splines_stay_within_a_hundredth() {
// A rational quadratic circle of radius 100: nine weighted control points.
let w = std::f64::consts::FRAC_1_SQRT_2.to_string();
let mut e: Vec<(i32, String)> = vec![(0, "SPLINE".into()), (70, "12".into()), (71, "2".into()), (72, "12".into()), (73, "9".into())];
for k in ["0", "0", "0", "1", "1", "2", "2", "3", "3", "4", "4", "4"] {
e.push((40, k.into()));
}
for i in 0..9 {
e.push((41, if i % 2 == 0 { "1".into() } else { w.clone() }));
}
for (x, y) in [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1), (1, 0)] {
e.extend([(10, (200 + x * 100).to_string()), (20, (200 + y * 100).to_string()), (30, "0".into())]);
}
let e: Vec<(i32, &str)> = e.iter().map(|(c, v)| (*c, v.as_str())).collect();
let doc = open(&entities(&e), &points()).document;
let [circle] = &leaves(&doc)[..] else { panic!() };
let h = height(&doc);
let worst = path_points(circle).iter().map(|p| (p.distance(Point::new(200.0, h - 200.0)) - 100.0).abs()).fold(0.0, f64::max);
assert!(worst < 0.01, "NURBS error {worst}");
// A spline given by fit points alone passes through them.
let e = [(0, "SPLINE"), (71, "3"), (74, "3"), (11, "0"), (21, "0"), (11, "50"), (21, "40"), (11, "100"), (21, "0")];
let doc = open(&entities(&e), &points()).document;
let [s] = &leaves(&doc)[..] else { panic!() };
let h = height(&doc);
assert!(path_points(s).iter().any(|p| p.distance(Point::new(50.0, h - 40.0)) < 1e-9));
}
/// A square with a round hole: a polyline loop and a loop of one edge (a whole arc).
fn hatch(solid: &'static str) -> Vec<(i32, &'static str)> {
vec![
(0, "HATCH"),
(62, "1"),
(10, "0"),
(20, "0"),
(30, "0"),
(2, if solid == "1" { "SOLID" } else { "ANSI31" }),
(70, solid),
(71, "0"),
(91, "2"),
(92, "3"),
(72, "0"),
(73, "1"),
(93, "4"),
(10, "0"),
(20, "0"),
(10, "100"),
(20, "0"),
(10, "100"),
(20, "100"),
(10, "0"),
(20, "100"),
(97, "0"),
(92, "0"),
(93, "1"),
(72, "2"),
(10, "50"),
(20, "50"),
(40, "20"),
(50, "0"),
(51, "360"),
(73, "1"),
(97, "0"),
(75, "0"),
(76, "1"),
(98, "0"),
]
}
#[test]
fn solid_hatches_fill_and_pattern_hatches_outline() {
let r = open(&entities(&hatch("1")), &points());
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let compound = r.document.layers[0].children().unwrap()[0].clone();
let NodeKind::Compound { children, rule } = &compound.kind else { panic!("{:?}", compound.kind) };
assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd), "the hole is a second loop");
assert_eq!(fill_rgb(&compound), [255, 0, 0, 255]);
assert!(compound.appearance.stroke().is_none_or(|s| s.paint.is_none()));
assert_eq!(bounds(&compound).area(), 10_000.0);
let r = open(&entities(&hatch("0")), &points());
assert!(r.warnings.iter().any(|w| w.contains("pattern hatches")), "{:?}", r.warnings);
let outline = r.document.layers[0].children().unwrap()[0].clone();
assert_eq!(stroke_rgb(&outline), [255, 0, 0, 255]);
// Solids fill their corners in zigzag order.
let e = [(0, "SOLID"), (10, "0"), (20, "0"), (11, "10"), (21, "0"), (12, "0"), (22, "10"), (13, "10"), (23, "10")];
let doc = open(&entities(&e), &points()).document;
let [square] = &leaves(&doc)[..] else { panic!() };
assert_eq!(bounds(square).area(), 100.0);
assert!(square.appearance.fill().is_some_and(|f| !f.paint.is_none()));
}
/// A solid hatch: a loop of a line and a spline edge (`fit` its fit data, if any), followed by
/// `source` (the loop's source-object count and handles), and a square hole.
fn spline_edged_hatch(fit: &[(i32, &'static str)], source: &[(i32, &'static str)]) -> Vec<(i32, &'static str)> {
let mut e = vec![(0, "HATCH"), (62, "1"), (10, "0"), (20, "0"), (30, "0"), (2, "SOLID"), (70, "1"), (71, "1"), (91, "2")];
e.extend([(92, "1"), (93, "2"), (72, "1"), (10, "0"), (20, "0"), (11, "100"), (21, "0")]);
e.extend([(72, "4"), (94, "3"), (73, "0"), (74, "0"), (95, "8"), (96, "4")]);
e.extend(["0", "0", "0", "0", "1", "1", "1", "1"].map(|k| (40, k)));
e.extend([(10, "100"), (20, "0"), (10, "100"), (20, "100"), (10, "0"), (20, "100"), (10, "0"), (20, "0")]);
e.extend_from_slice(fit);
e.extend_from_slice(source);
e.extend([(92, "2"), (72, "0"), (73, "1"), (93, "4"), (10, "40"), (20, "20"), (10, "60"), (20, "20")]);
e.extend([(10, "60"), (20, "40"), (10, "40"), (20, "40"), (97, "0"), (75, "0"), (76, "1"), (98, "0")]);
e
}
#[test]
fn hatch_spline_edges_with_or_without_fit_data() {
// Before DXF 2010 a spline edge has no fit data, so a 97 right after it is the loop's own
// count of source objects; from 2010 on it has a fit count, here 0, before that.
let source = [(97, "1"), (330, "1F")];
for fit in [&[][..], &[(97, "0")][..]] {
let r = open(&entities(&spline_edged_hatch(fit, &source)), &points());
let [hatch] = &r.document.layers[0].children().unwrap()[..] else { panic!("fit data {fit:?}: the hatch is lost") };
let NodeKind::Compound { children, .. } = &hatch.kind else { panic!("{:?}", hatch.kind) };
assert_eq!(children.len(), 2, "the outline and the hole, fit data {fit:?}");
// The spline edge peaks three quarters of the way up its control polygon.
let b = bounds(hatch);
assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 75.0).abs() < 1e-6, "{b:?}");
}
// Fit points after the count are still read (and ignored when there are control points).
let fit = [(97, "2"), (11, "100"), (21, "0"), (11, "0"), (21, "0")];
let r = open(&entities(&spline_edged_hatch(&fit, &source)), &points());
assert_eq!(r.document.layers[0].children().unwrap().len(), 1);
}
#[test]
fn text_and_multiline_text() {
let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Notes"), (3, "DejaVuSans.ttf"), (0, "ENDTAB")];
let e = [
(0, "TEXT"),
(10, "10"),
(20, "10"),
(40, "7"),
(1, "Room %%c20"),
(50, "90"),
(7, "Notes"),
(0, "MTEXT"),
(10, "100"),
(20, "100"),
(40, "3.5"),
(71, "2"),
(3, "{\\fArial|b0;First}"),
(1, "\\PSecond"),
];
let doc = open(&drawing(&[], &tables, &[], &e), &points()).document;
let [single, multi] = &leaves(&doc)[..] else { panic!() };
let h = height(&doc);
let t = text_of(single);
assert_eq!(t.plain_text(), "Room ⌀20");
let st = t.first_style();
assert!((st.size - 10.0).abs() < 1e-9, "a 7-unit cap height is 10 pt type: {}", st.size);
assert_eq!(st.font_family, "DejaVuSans");
// Turned a quarter counter-clockwise, its baseline starting at the insertion point.
let origin = t.xf * Point::ZERO;
assert!(origin.distance(Point::new(10.0, h - 10.0)) < 1e-9, "{origin:?}");
let along = t.xf * Point::new(1.0, 0.0) - origin;
assert!(along.x.abs() < 1e-9 && (along.y + 1.0).abs() < 1e-9, "{along:?}");
let m = text_of(multi);
assert_eq!(m.plain_text(), "First\nSecond");
assert_eq!(m.para.justify, Justify::Center, "attached top centre");
assert_eq!(m.first_style().font_family, "Arial");
// Top attachment: the first baseline one cap height below the insertion point.
let o = m.xf * Point::ZERO;
assert!(o.distance(Point::new(100.0, h - 100.0 + 3.5)) < 1e-9, "{o:?}");
let leading = m.first_style().leading.unwrap();
assert!((leading - 3.5 * 5.0 / 3.0).abs() < 1e-9, "{leading}");
}
/// Where the first line's baseline starts and ends, in document coordinates.
fn baseline_ends(t: &TextObject) -> (Point, Point) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let l = &layout.lines[0];
(t.xf * Point::new(l.x0, l.baseline), t.xf * Point::new(l.x1, l.baseline))
}
#[test]
fn fit_and_aligned_text_span_their_two_points() {
// Millimetres: red lines at x = 10 and 90, fit and aligned text between them, and the same
// text left-justified for its natural size.
let e = [
(0, "LINE"),
(62, "1"),
(10, "10"),
(20, "0"),
(11, "10"),
(21, "60"),
(0, "LINE"),
(62, "1"),
(10, "90"),
(20, "0"),
(11, "90"),
(21, "60"),
(0, "TEXT"),
(10, "10"),
(20, "40"),
(40, "5"),
(1, "FIT BETWEEN"),
(72, "5"),
(11, "90"),
(21, "40"),
(0, "TEXT"),
(10, "10"),
(20, "10"),
(40, "5"),
(41, "0.8"),
(1, "ALIGNED"),
(72, "3"),
(11, "90"),
(21, "10"),
(0, "TEXT"),
(10, "10"),
(20, "25"),
(40, "5"),
(1, "FIT BETWEEN"),
(0, "TEXT"),
(10, "10"),
(20, "50"),
(40, "5"),
(41, "0.8"),
(1, "ALIGNED"),
];
let o = ImportOptions { center: false, ..ImportOptions::default() };
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
let [left, right, fit, aligned, fit_natural, aligned_natural] = &leaves(&doc)[..] else { panic!() };
let (x10, x90) = (bounds(left).center().x, bounds(right).center().x);
for n in [fit, aligned] {
let (a, b) = baseline_ends(text_of(n));
assert!((a.x - x10).abs() < 1e-3 && (b.x - x90).abs() < 1e-3, "{a:?} to {b:?}, not {x10} to {x90}");
assert!((a.y - b.y).abs() < 1e-9, "level");
}
// Fit keeps its height and stretches across.
let (st, natural) = (text_of(fit).first_style(), text_of(fit_natural).first_style());
assert_eq!(st.size, natural.size);
assert!(st.h_scale > 100.0, "{}", st.h_scale);
// Aligned grows as a whole, its width factor kept.
let (a, b) = baseline_ends(text_of(aligned_natural));
let k = (x90 - x10) / (b.x - a.x);
let (st, natural) = (text_of(aligned).first_style(), text_of(aligned_natural).first_style());
assert!((st.size - natural.size * k).abs() < 1e-6 && k > 2.0, "{} vs {} × {k}", st.size, natural.size);
assert!((st.h_scale - 80.0).abs() < 1e-9 && (natural.h_scale - 80.0).abs() < 1e-9);
}
#[test]
fn fit_and_aligned_text_without_a_second_point_keep_their_size() {
for h in ["3", "5"] {
let e = [
(0, "TEXT"),
(10, "10"),
(20, "10"),
(40, "7"),
(1, "Gap"),
(72, h),
(0, "TEXT"),
(10, "10"),
(20, "30"),
(40, "7"),
(1, "Gap"),
(72, h),
(11, "10"),
(21, "30"),
];
let doc = open(&entities(&e), &points()).document;
let top = height(&doc);
for (n, y) in leaves(&doc).iter().zip([10.0, 30.0]) {
let t = text_of(n);
let st = t.first_style();
assert!((st.size - 10.0).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
assert!((t.xf * Point::ZERO).distance(Point::new(10.0, top - y)) < 1e-9);
}
}
}
#[test]
fn fit_and_aligned_text_scale_within_limits() {
// A span a billion units long and one a millionth of a unit long.
let e = [
(0, "TEXT"),
(10, "0"),
(20, "0"),
(40, "7"),
(1, "X"),
(72, "5"),
(11, "1e9"),
(21, "0"),
(0, "TEXT"),
(10, "0"),
(20, "10"),
(40, "7"),
(1, "X"),
(72, "3"),
(11, "1e-6"),
(21, "10"),
];
let doc = open(&entities(&e), &points()).document;
let [fit, aligned] = &leaves(&doc)[..] else { panic!() };
let st = text_of(fit).first_style();
assert!((st.size - 10.0).abs() < 1e-9 && (st.h_scale - 10_000.0).abs() < 1e-6, "{} at {}%", st.size, st.h_scale);
let st = text_of(aligned).first_style();
assert!((st.size - 0.1).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
}
#[test]
fn fit_attributes_span_their_two_points() {
let inserts = [
(0, "INSERT"),
(66, "1"),
(2, "Door"),
(10, "0"),
(20, "0"),
(0, "ATTRIB"),
(10, "10"),
(20, "20"),
(40, "7"),
(1, "D-01"),
(2, "TAG"),
(70, "0"),
(72, "5"),
(74, "0"),
(11, "90"),
(21, "20"),
(0, "SEQEND"),
];
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
let tag = leaves(&doc).into_iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap();
let (a, b) = baseline_ends(text_of(&tag));
assert!((a.x - 10.0).abs() < 1e-3 && (b.x - 90.0).abs() < 1e-3, "{a:?} to {b:?}");
assert_eq!(text_of(&tag).first_style().size, 10.0);
}
/// A block "Door" (base point 5, 5) holding `block_art`, and `inserts`.
fn with_door(block_art: &[(i32, &'static str)], inserts: &[(i32, &'static str)]) -> Vec<u8> {
let mut blocks = vec![(0, "BLOCK"), (8, "0"), (2, "Door"), (70, "0"), (10, "5"), (20, "5"), (3, "Door")];
blocks.extend_from_slice(block_art);
blocks.push((0, "ENDBLK"));
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "Doors"), (70, "0"), (62, "5"), (6, "CONTINUOUS"), (0, "ENDTAB")];
drawing(&[], &tables, &blocks, inserts)
}
/// A red line 10 units along x from the base point, on layer 0.
const DOOR_LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "1"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
#[test]
fn inserts_become_symbol_instances_with_their_transform() {
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "100"), (20, "50"), (41, "2"), (42, "3"), (50, "90")];
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
assert_eq!(doc.symbols.len(), 1);
assert_eq!(doc.symbols[0].name, "Door");
let [inst] = &leaves(&doc)[..] else { panic!() };
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { panic!("{:?}", inst.kind) };
assert_eq!(symbol, "Door");
assert_eq!(doc.layers[0].name.as_deref(), Some("Doors"));
// Scaled 2 and turned a quarter: from the insertion point 20 units up.
let art = &doc.symbols[0].art;
let pts: Vec<Point> = path_points(art).iter().map(|p| *xf * *p).collect();
let h = height(&doc);
let (a, b) = (pts.first().unwrap(), pts.last().unwrap());
assert!(a.distance(Point::new(100.0, h - 50.0)) < 1e-9, "{a:?}");
assert!(b.distance(Point::new(100.0, h - 70.0)) < 1e-9, "{b:?}");
assert_eq!(stroke_rgb(art), [255, 0, 0, 255], "the block's own colour");
}
#[test]
fn by_block_colours_make_a_symbol_per_look() {
// The block's line takes the insert's colour: red and green make two symbols; a third red
// insert reuses the first.
let by_block: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
let inserts = [
(0, "INSERT"),
(2, "Door"),
(62, "1"),
(10, "0"),
(20, "0"),
(0, "INSERT"),
(2, "Door"),
(62, "3"),
(10, "50"),
(20, "0"),
(0, "INSERT"),
(2, "Door"),
(62, "1"),
(10, "100"),
(20, "0"),
];
let doc = open(&with_door(by_block, &inserts), &points()).document;
let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["Door", "Door 2"]);
assert_eq!(stroke_rgb(&doc.symbols[0].art), [255, 0, 0, 255]);
assert_eq!(stroke_rgb(&doc.symbols[1].art), [0, 255, 0, 255]);
assert_eq!(leaves(&doc).len(), 3);
// Layer 0 art takes the insert's layer's colour.
let on_zero: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "0"), (20, "0"), (0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "40"), (20, "0")];
let doc = open(&with_door(on_zero, &inserts), &points()).document;
assert_eq!(doc.symbols.len(), 1);
assert_eq!(stroke_rgb(&doc.symbols[0].art), [0, 0, 255, 255], "layer Doors is blue");
// Art with its own colour shares one symbol, whatever the inserts' colours.
let inserts = [(0, "INSERT"), (2, "Door"), (62, "1"), (10, "0"), (20, "0"), (0, "INSERT"), (2, "Door"), (62, "3"), (10, "40"), (20, "0")];
let doc = open(&with_door(&[(0, "LINE"), (8, "Doors"), (62, "4"), (10, "5"), (20, "5"), (11, "15"), (21, "5")], &inserts), &points()).document;
assert_eq!(doc.symbols.len(), 1);
}
#[test]
fn array_inserts_attributes_and_dimensions() {
let inserts = [
(0, "INSERT"),
(66, "1"),
(2, "Door"),
(10, "0"),
(20, "0"),
(70, "3"),
(71, "2"),
(44, "20"),
(45, "30"),
(0, "ATTRIB"),
(10, "0"),
(20, "-10"),
(40, "2"),
(1, "D-01"),
(2, "TAG"),
(70, "0"),
(0, "ATTRIB"),
(10, "0"),
(20, "-20"),
(40, "2"),
(1, "hidden"),
(2, "NOTE"),
(70, "1"),
(0, "SEQEND"),
];
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
let all = leaves(&doc);
let instances: Vec<Point> =
all.iter().filter_map(|n| if let NodeKind::SymbolInstance { xf, .. } = &n.kind { Some(*xf * Point::ZERO) } else { None }).collect();
assert_eq!(instances.len(), 6, "3 columns × 2 rows");
let xs: std::collections::BTreeSet<i64> = instances.iter().map(|p| p.x.round() as i64).collect();
assert_eq!(xs.len(), 3, "columns 20 apart: {instances:?}");
let texts: Vec<String> = all.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
assert_eq!(texts, ["D-01"], "invisible attributes stay out");
// A dimension draws its anonymous block as art, not a symbol.
let blocks = [(0, "BLOCK"), (2, "*D1"), (70, "1"), (10, "0"), (20, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "50"), (21, "0"), (0, "ENDBLK")];
let dims = [(0, "DIMENSION"), (2, "*D1"), (10, "0"), (20, "0")];
let doc = open(&drawing(&[], &[], &blocks, &dims), &points()).document;
assert!(doc.symbols.is_empty());
assert_eq!(bounds(&leaves(&doc)[0]).width(), 50.0);
}
#[test]
fn blocks_inside_themselves_are_left_out_without_hanging() {
let blocks = [
(0, "BLOCK"),
(2, "Loop"),
(10, "0"),
(20, "0"),
(0, "LINE"),
(10, "0"),
(20, "0"),
(11, "10"),
(21, "0"),
(0, "INSERT"),
(2, "Loop"),
(10, "20"),
(20, "0"),
(0, "ENDBLK"),
];
let r = open(&drawing(&[], &[], &blocks, &[(0, "INSERT"), (2, "Loop"), (10, "0"), (20, "0")]), &points());
assert!(r.warnings.iter().any(|w| w.contains("inside themselves")), "{:?}", r.warnings);
assert_eq!(r.document.symbols.len(), 1);
}
#[test]
fn layers_keep_their_state_colour_lineweight_and_linetype() {
let tables = [
(0, "TABLE"),
(2, "LTYPE"),
(0, "LTYPE"),
(2, "DASHED"),
(73, "2"),
(40, "15"),
(49, "10"),
(49, "-5"),
(0, "ENDTAB"),
(0, "TABLE"),
(2, "LAYER"),
(0, "LAYER"),
(2, "Hidden"),
(70, "0"),
(62, "-3"),
(6, "DASHED"),
(370, "50"),
(0, "LAYER"),
(2, "Frozen"),
(70, "1"),
(62, "2"),
(0, "LAYER"),
(2, "Locked"),
(70, "4"),
(62, "1"),
(420, "16744448"),
(290, "0"),
(0, "ENDTAB"),
];
let line = |layer: &'static str| [(0, "LINE"), (8, layer), (10, "0"), (20, "0"), (11, "100"), (21, "0")];
let e: Vec<(i32, &str)> = [line("Hidden"), line("Frozen"), line("Locked"), line("Unlisted")].concat();
let doc = open(&drawing(&[], &tables, &[], &e), &points()).document;
let names: Vec<&str> = doc.layers.iter().filter_map(|l| l.name.as_deref()).collect();
assert_eq!(names, ["Hidden", "Frozen", "Locked", "Unlisted"]);
let [hidden, frozen, locked, plain] = &doc.layers[..] else { panic!() };
assert!(!hidden.visible && !frozen.visible && locked.visible && plain.visible);
assert!(locked.locked && !hidden.locked);
assert!(matches!(locked.kind, NodeKind::Layer { printable: false, .. }));
let first = |l: &Arc<Node>| l.children().unwrap()[0].clone();
// Colour 3 (green) and a 0.5 mm dashed line from the layer.
let h = first(hidden);
assert_eq!(stroke_rgb(&h), [0, 255, 0, 255]);
let st = h.appearance.stroke().unwrap();
assert!((st.width - 0.5 * 72.0 / 25.4).abs() < 1e-9);
assert_eq!(st.dash.as_ref().unwrap().pattern, [10.0, 5.0]);
assert_eq!(stroke_rgb(&first(frozen)), [255, 255, 0, 255]);
assert_eq!(stroke_rgb(&first(locked)), [255, 128, 0, 255], "true colour wins over the index");
// Merge Layers: one layer.
let merged = open(&drawing(&[], &tables, &[], &e), &ImportOptions { merge_layers: true, ..points() }).document;
assert_eq!(merged.layers.len(), 1);
assert_eq!(merged.layers[0].children().unwrap().len(), 4);
}
#[test]
fn entity_colours_lineweights_and_transparency() {
let e = [
(0, "LINE"),
(62, "30"),
(370, "100"),
(440, "33554559"),
(10, "0"),
(20, "0"),
(11, "10"),
(21, "0"),
(0, "LINE"),
(62, "5"),
(420, "65280"),
(370, "0"),
(10, "0"),
(20, "10"),
(11, "10"),
(21, "10"),
];
let doc = open(&entities(&e), &points()).document;
let [a, b] = &leaves(&doc)[..] else { panic!() };
assert_eq!(stroke_rgb(a), [255, 127, 0, 255], "index 30 is orange");
assert!((a.appearance.stroke().unwrap().width - 72.0 / 25.4).abs() < 1e-9, "1 mm");
assert!((a.opacity - 127.0 / 255.0).abs() < 1e-6);
assert_eq!(stroke_rgb(b), [0, 255, 0, 255]);
assert_eq!(b.appearance.stroke().unwrap().width, 0.25, "lineweight 0 is the thinnest line");
// Scale Lineweights: at 1 pt = 10 units (a tenth of 1:1 in millimetres) they shrink too.
let o = ImportOptions { unit: Some(Unit::Points), scale: Some(10.0), scale_lineweights: true, ..ImportOptions::default() };
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
let w = leaves(&doc)[0].appearance.stroke().unwrap().width;
let k = 0.1 / (72.0 / 25.4);
assert!((w - 72.0 / 25.4 * k).abs() < 1e-9, "{w}");
}
#[test]
fn fit_and_centre_place_the_art_on_the_artboard() {
let doc = open(&entities(LINE), &ImportOptions { fit: true, ..ImportOptions::default() }).document;
let board = doc.artboards[0].rect;
assert_eq!((board.width(), board.height()), (792.0, 612.0), "landscape letter for wide art");
let b = bounds(&leaves(&doc)[0]);
assert!((b.width() - 792.0).abs() < 1e-6 && (b.center().y - 306.0).abs() < 1e-6, "{b:?}");
let o = ImportOptions { fit: true, fit_to: (200.0, 100.0), center: false, ..ImportOptions::default() };
let b = bounds(&leaves(&open(&entities(LINE), &o).document)[0]);
assert!(close(b, Rect::new(0.0, 0.0, 200.0, 100.0), 1e-6), "bottom-left: {b:?}");
// Centred at a ratio, the artboard is the art's bounds.
let doc = open(&entities(LINE), &ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), ..ImportOptions::default() }).document;
assert!(close(bounds(&leaves(&doc)[0]), doc.artboards[0].rect, 1e-9));
// Art too large for the canvas at the scale asked for says so.
let o = ImportOptions { unit: Some(Unit::Meters), scale: Some(0.001), ..ImportOptions::default() };
let e = import(&entities(LINE), &o).unwrap_err();
assert!(e.contains("canvas"), "{e}");
let e = import(&entities(LINE), &ImportOptions { scale: Some(-1.0), ..ImportOptions::default() }).unwrap_err();
assert!(e.contains("scale"), "{e}");
}
#[test]
fn paper_layouts() {
let mut e = LINE.to_vec();
e.extend([(0, "CIRCLE"), (67, "1"), (10, "0"), (20, "0"), (40, "5")]);
let bytes = entities(&e);
assert_eq!(info(&bytes).unwrap().layouts, ["Model", "Layout1"]);
let model = leaves(&open(&bytes, &points()).document);
assert_eq!(model.len(), 1);
assert_eq!(bounds(&model[0]).width(), 100.0, "model space holds the line");
let paper = leaves(&open(&bytes, &ImportOptions { layout: Some("layout1".into()), ..points() }).document);
assert_eq!(bounds(&paper[0]).width(), 10.0, "the paper layout holds the circle");
let e = import(&bytes, &ImportOptions { layout: Some("Sheet".into()), ..points() }).unwrap_err();
assert!(e.contains("Model, Layout1"), "{e}");
}
#[test]
fn binary_dwg_damaged_and_foreign_files() {
let mut binary = BINARY_SENTINEL.to_vec();
binary.extend([0u8; 40]);
assert!(is_dxf(&binary));
let e = import(&binary, &ImportOptions::default()).unwrap_err();
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err();
assert!(e.contains("DWG"), "{e}");
for junk in [&b""[..], b"hello", b"<svg/>", b"999\nnote\n"] {
assert!(import(junk, &ImportOptions::default()).is_err(), "{junk:?}");
assert!(!is_dxf(junk), "{junk:?}");
}
assert!(is_dxf(b"999\nwritten by hand\n 0\nSECTION\n 2\nHEADER\n"));
// A file cut off in the middle keeps what came before.
let mut cut = entities(LINE);
cut.truncate(cut.len() - 30);
cut.extend(b"\nnot a code\n");
let r = open(&cut, &points());
assert_eq!(leaves(&r.document).len(), 1);
assert!(r.warnings.iter().any(|w| w.contains("damaged")), "{:?}", r.warnings);
// Hostile numbers are dropped; unknown entities are listed.
let e =
[(0, "CIRCLE"), (10, "1e999"), (20, "NaN"), (40, "1e300"), (0, "XLINE"), (10, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "1"), (21, "1")];
let r = open(&entities(&e), &points());
assert!(r.warnings.iter().any(|w| w.contains("1 XLINE")), "{:?}", r.warnings);
}
#[test]
fn drawings_the_export_writes_read_back() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let id = doc.alloc_id();
let rect = shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0));
let blue = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 255)), 2.0);
doc.insert(Some(layer), 0, Node::path(id, rect, blue)).unwrap();
let id = doc.alloc_id();
let circle = shapes::ellipse(Rect::new(140.0, 20.0, 180.0, 60.0));
doc.insert(Some(layer), 1, Node::path(id, circle, Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0))).unwrap();
for version in [crate::DxfVersion::R12, crate::DxfVersion::R2000, crate::DxfVersion::R2018] {
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
let out = crate::export(&doc, &opts).unwrap();
let back = open(&out.bytes, &points());
let art = leaves(&back.document);
// Same place on the artboard (the export's origin is the artboard's bottom-left).
let shift = Affine::translate((0.0, 100.0 - height(&back.document)));
let all = art.iter().filter_map(|n| n.geometric_bounds()).map(|b| shift.transform_rect_bbox(b)).reduce(|a, b| a.union(b)).unwrap();
assert!(close(all, Rect::new(20.0, 20.0, 180.0, 70.0), 0.1), "{version:?}: {all:?}");
let stroked = art.iter().find(|n| n.appearance.stroke().is_some_and(|s| !s.paint.is_none())).unwrap();
assert_eq!(stroke_rgb(stroked), [0, 0, 255, 255], "{version:?}");
if version >= crate::DxfVersion::R2000 {
assert!(art.iter().any(|n| n.appearance.fill().is_some_and(|f| !f.paint.is_none())), "{version:?}: the fill comes back as a hatch");
let w = stroked.appearance.stroke().unwrap().width;
assert!((w - 2.0).abs() < 0.2, "{version:?}: lineweight {w}");
}
}
}
#[test]
fn info_lists_layers_layouts_and_version() {
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")];
let i = info(&drawing(&[(9, "$ACADVER"), (1, "AC1015"), (9, "$INSUNITS"), (70, "6")], &tables, &[], LINE)).unwrap();
assert_eq!(i.version, "2000");
assert_eq!(i.units, "Meters");
assert_eq!((i.unit, i.scale), (Unit::Meters, 1.0));
assert_eq!(i.layers, ["0", "Walls"]);
assert_eq!(i.layouts, ["Model"]);
assert!(info(b"not a drawing").is_err());
}
#[test]
fn entities_extruded_along_minus_z_are_mirrored() {
let e = [(0, "CIRCLE"), (10, "50"), (20, "0"), (40, "10"), (210, "0"), (220, "0"), (230, "-1")];
let doc = open(&entities(&e), &points()).document;
let b = bounds(&leaves(&doc)[0]);
assert!((b.center().x + 50.0).abs() < 1e-6, "the centre's x is mirrored: {b:?}");
}
+157
View File
@@ -0,0 +1,157 @@
//! CAD text values as plain text: control codes (`%%d`), Unicode escapes (`\U+00B0`), caret
//! escapes (`^I`) and, in multiline text, the formatting codes (`\P` paragraph breaks, `{\fArial;…}`
//! font changes, `\S1/2;` stacked fractions…).
/// A single-line text value (TEXT, ATTRIB) as plain text.
pub(crate) fn plain(s: &str) -> String {
percent_codes(&escapes(s))
}
/// A multiline text value (MTEXT) as plain text with `\n` between paragraphs, and the font family
/// its first font change names.
pub(crate) fn mtext(s: &str) -> (String, Option<String>) {
let s = escapes(s);
let mut out = String::with_capacity(s.len());
let mut family = None;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' | '}' => {}
'\\' => match chars.next() {
Some('P' | 'X' | 'N') => out.push('\n'),
Some('~') => out.push('\u{A0}'),
Some(c @ ('\\' | '{' | '}')) => out.push(c),
Some('L' | 'l' | 'O' | 'o' | 'K' | 'k') => {}
Some('S') => {
// A stacked fraction `\Snum^den;` (or / or #): written as num/den.
let arg = until_semicolon(&mut chars);
out.push_str(&arg.replace(['^', '#'], "/"));
}
Some(f @ ('f' | 'F')) => {
let arg = until_semicolon(&mut chars);
if family.is_none() && f == 'f' {
family = arg.split('|').next().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
}
}
// Height, colour, tracking, oblique, width, alignment, paragraph: settings with
// an argument.
Some('H' | 'C' | 'c' | 'T' | 'Q' | 'W' | 'A' | 'p') => {
until_semicolon(&mut chars);
}
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
},
c => out.push(c),
}
}
(percent_codes(&out), family)
}
/// The characters up to the next `;` (dropped).
fn until_semicolon(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
let mut arg = String::new();
for c in chars.by_ref() {
if c == ';' {
break;
}
arg.push(c);
}
arg
}
/// `\U+XXXX` Unicode escapes (how files before DXF 2007 hold characters outside their code page)
/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped.
fn escapes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(c) = rest.chars().next() {
let after = rest.get(c.len_utf8()..).unwrap_or("");
if c == '\\'
&& let Some(hex) = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+")).and_then(|h| h.get(..4))
&& let Some(ch) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
{
out.push(ch);
rest = after.get(6..).unwrap_or("");
continue;
}
if c == '^'
&& let Some(next) = after.chars().next()
{
match next {
' ' => out.push('^'),
'I' => out.push('\t'),
_ => {}
}
rest = after.get(next.len_utf8()..).unwrap_or("");
continue;
}
if !c.is_control() || c == '\t' {
out.push(c);
}
rest = after;
}
out
}
/// `%%` control codes: `%%d` degree, `%%p` plus-minus, `%%c` diameter, `%%%` percent, `%%nnn` a
/// character code; the underline, overline and strike-through toggles (`%%u`, `%%o`, `%%k`) are
/// dropped.
fn percent_codes(s: &str) -> String {
if !s.contains("%%") {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find("%%") {
out.push_str(rest.get(..i).unwrap_or(""));
let code = rest.get(i + 2..).unwrap_or("");
let mut skip = 1;
match code.chars().next().map(|c| c.to_ascii_lowercase()) {
Some('d') => out.push('°'),
Some('p') => out.push('±'),
Some('c') => out.push('⌀'),
Some('%') => out.push('%'),
Some('u' | 'o' | 'k') => {}
Some(c) if c.is_ascii_digit() => {
let digits: String = code.chars().take(3).take_while(char::is_ascii_digit).collect();
skip = digits.len();
out.extend(digits.parse::<u32>().ok().and_then(char::from_u32).filter(|c| !c.is_control()));
}
_ => {
out.push_str("%%");
skip = 0;
}
}
rest = code.get(skip..).unwrap_or("");
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_codes_and_escapes() {
assert_eq!(plain("45%%d %%p0.1 %%c20 100%%% %%uunder%%u"), "45° ±0.1 ⌀20 100% under");
assert_eq!(plain("caf\\U+00E9 ^ x^I^Jy"), "café ^x\ty", "`^ ` is a caret");
assert_eq!(plain("%%065%%066"), "AB");
assert_eq!(plain("50%% off"), "50%% off", "an unknown code stays");
assert_eq!(plain("\\U+"), "\\U+");
}
#[test]
fn mtext_formatting_is_dropped() {
let (t, family) = mtext("{\\fArial|b1|i0|c0|p34;Title}\\P\\H2.5x;Line \\C1;two\\~\\S1/2;\\Pa\\\\b {\\Lunder}");
assert_eq!(t, "Title\nLine two\u{A0}1/2\na\\b under");
assert_eq!(family.as_deref(), Some("Arial"));
// Carets are written "^ " (a caret before another character is a control character).
assert_eq!(mtext("x\\S3^ 4;").0, "x3/4");
assert_eq!(mtext("x\\S3#4;").0, "x3/4");
assert_eq!(mtext("trailing\\").0, "trailing\\");
}
}
+344
View File
@@ -0,0 +1,344 @@
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader.
//!
//! [`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.
//!
//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`].
//!
//! - `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.
//! - `import`: the reader, entities to art, blocks to symbols.
mod aci;
pub mod import;
mod scene;
mod writer;
use vectorcraft_doc::{Document, Unit};
use vectorcraft_geom::Rect;
pub use aci::{aci_rgb, nearest_aci};
pub use import::{DxfInfo, ImportOptions, Imported, import, info, is_dxf};
/// How deep symbols, blocks 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;
/// The `$INSUNITS` codes of the units documents use (feet and inches write feet).
const INSUNITS: [(u8, Unit); 7] =
[(1, Unit::Inches), (2, Unit::Feet), (2, Unit::FeetInches), (4, Unit::Millimeters), (5, Unit::Centimeters), (6, Unit::Meters), (10, Unit::Yards)];
/// The `$INSUNITS` code of `unit` (none for points, picas and pixels).
fn insunits_code(unit: Unit) -> Option<u8> {
INSUNITS.iter().find(|(_, u)| *u == unit).map(|(c, _)| *c)
}
/// 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;
+842
View File
@@ -0,0 +1,842 @@
//! 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::{CAP_HEIGHT, ColorDepth, DxfImage, DxfOptions, DxfOutput, MAX_NEST, Preserve, RasterFormat};
/// Curves are flattened to within this distance (points) where DXF needs straight segments
/// (hatch boundaries, R12 polylines).
const FLATNESS: f64 = 0.05;
/// 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 = if one { crate::insunits_code(self.o.unit).unwrap_or(0) } else { 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_effects::expand_live_deep(Some(self.doc), n);
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)
}
+135 -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,
}
}
@@ -598,6 +662,60 @@ impl ProofLut {
}
}
/// A 17⁴ CMYK → four values lookup table with quadrilinear interpolation: ink amounts to a
/// colour (XYZ, RGB, or CMYK in another space).
pub struct CmykLut {
data: Vec<[f32; 4]>,
}
impl CmykLut {
pub fn build(f: impl Fn([f32; 4]) -> [f32; 4]) -> Self {
let s = (LUT_N - 1) as f32;
let mut data = Vec::with_capacity(LUT_N.pow(4));
for c in 0..LUT_N {
for m in 0..LUT_N {
for y in 0..LUT_N {
for k in 0..LUT_N {
data.push(f([c, m, y, k].map(|i| i as f32 / s)));
}
}
}
}
Self { data }
}
pub fn apply(&self, inks: [f32; 4]) -> [f32; 4] {
const STRIDE: [usize; 4] = [LUT_N * LUT_N * LUT_N, LUT_N * LUT_N, LUT_N, 1];
let p = inks.map(|v| v.clamp(0.0, 1.0) * (LUT_N - 1) as f32);
let i = p.map(|v| (v as usize).min(LUT_N - 2));
let base: usize = (0..4).map(|d| i[d] * STRIDE[d]).sum();
let mut out = [0.0f32; 4];
for corner in 0..16 {
let (mut w, mut at) = (1.0f32, base);
for d in 0..4 {
let t = p[d] - i[d] as f32;
if corner >> d & 1 == 1 {
w *= t;
at += STRIDE[d];
} else {
w *= 1.0 - t;
}
}
if w > 0.0 {
for (o, v) in out.iter_mut().zip(self.data[at]) {
*o += w * v;
}
}
}
out
}
/// [`Self::apply`] to 8-bit ink amounts, for 8-bit values.
pub fn apply8(&self, inks: [u8; 4]) -> [u8; 4] {
self.apply(inks.map(|v| v as f32 / 255.0)).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
}
}
// ---------- the active (process-wide) settings ----------
static ACTIVE: RwLock<Option<Arc<Cms>>> = RwLock::new(None);
@@ -636,3 +754,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}");
}
}
}
+4
View File
@@ -12,9 +12,13 @@ workspace = true
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
tiff = { workspace = true }
zune-jpeg = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
jpeg-encoder = { workspace = true }
proptest = { workspace = true }
File diff suppressed because it is too large. Load diff
+57
View File
@@ -0,0 +1,57 @@
//! Asset Export (Window → Asset Export, Object → Collect for Export): pieces of art collected to
//! export on their own, at the export settings Export for Screens shares.
//!
//! An asset names objects rather than copying them: it follows edits (a moved object moves the
//! crop, a recoloured one exports recoloured) and lets go of objects that are deleted
//! ([`Document::prune_assets`]; an asset left with none goes too). Asset ids come from the
//! document's id counter, so they never repeat within a document.
use serde::{Deserialize, Serialize};
use crate::{Document, NodeId};
/// One asset: what it is called (its files' names) and the objects it exports, cropped to their
/// visual bounds.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExportAsset {
pub id: u64,
pub name: String,
pub nodes: Vec<NodeId>,
}
/// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`…
const ASSET: &str = "Asset ";
impl Document {
/// Asset `id`.
pub fn asset(&self, id: u64) -> Option<&ExportAsset> {
self.assets.iter().find(|a| a.id == id)
}
/// The name of the next unnamed asset: one more than the highest `Asset N` so far.
pub fn next_asset_name(&self) -> String {
let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::<u64>().ok()).max().unwrap_or(0);
format!("{ASSET}{}", n.saturating_add(1))
}
/// Forget the objects of assets that are no longer in the document, and the assets left with
/// none (after every edit).
pub fn prune_assets(&mut self) {
if self.assets.iter().all(|a| a.nodes.iter().all(|id| self.node(*id).is_some())) {
return;
}
let mut assets = std::mem::take(&mut self.assets);
for a in &mut assets {
a.nodes.retain(|id| self.node(*id).is_some());
}
assets.retain(|a| !a.nodes.is_empty());
self.assets = assets;
}
/// `ids` that are in the document, back to front.
pub fn paint_order(&self, ids: impl IntoIterator<Item = NodeId>) -> Vec<NodeId> {
let mut v: Vec<(Vec<usize>, NodeId)> = ids.into_iter().filter_map(|id| self.index_path(id).map(|p| (p, id))).collect();
v.sort();
v.into_iter().map(|(_, id)| id).collect()
}
}
File diff suppressed because it is too large. Load diff
+82
View File
@@ -0,0 +1,82 @@
//! Clipped and grouped art read from page descriptions (metafiles, PostScript) into clipping
//! groups and groups.
//!
//! Importers record each object they draw with the chain of clips (and groups) in force at the
//! time, outermost first ([`Drawn`]); [`nest`] then puts runs of objects under the same clip into
//! one clipping group (under the same group into one group), nesting groups for nested clips,
//! keeping the drawing order.
use std::sync::Arc;
use vectorcraft_geom::{BezPath, FillRule, PathData};
use crate::appearance::Appearance;
use crate::{Document, Node, NodeKind};
/// A clip: a region in document space and its rule, or a plain group (one that holds its art
/// without clipping it, such as an Illustrator file's `u` … `U`). Each has an id unique within an
/// import, so what one clip clips (or one group holds) can be grouped.
#[derive(Debug)]
pub struct Clip {
pub id: u32,
/// The region and its rule; `None` for a plain group.
pub region: Option<(BezPath, FillRule)>,
}
/// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper
/// for each): deeper ones are left out.
pub const MAX_NEST: usize = 128;
/// The warning for clips left out for nesting deeper than [`MAX_NEST`].
pub fn deep_clips() -> String {
format!("clips nested more than {MAX_NEST} deep were left out: what they clip is clipped by the clips around them")
}
/// Objects in drawing order, each with its clips (outermost first).
pub type Drawn = Vec<(Vec<Arc<Clip>>, Node)>;
/// The nodes of `items`, those under clips grouped into clipping groups and those in groups into
/// groups (ids from `doc`), in order.
pub fn nest(doc: &mut Document, items: Drawn) -> Vec<Arc<Node>> {
nest_at(doc, items, 0)
}
/// [`nest`] for the clips at `depth` and deeper.
fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec<Arc<Node>> {
let mut out: Vec<Arc<Node>> = vec![];
let mut group: Drawn = vec![];
let mut clip: Option<Arc<Clip>> = None;
for (chain, node) in items {
let here = chain.get(depth).cloned();
if here.as_ref().map(|c| c.id) != clip.as_ref().map(|c| c.id) {
if let Some(c) = clip.take() {
out.push(clip_group(doc, &c, std::mem::take(&mut group), depth));
}
clip = here.clone();
}
match here {
Some(_) => group.push((chain, node)),
None => out.push(Arc::new(node)),
}
}
if let Some(c) = clip {
out.push(clip_group(doc, &c, group, depth));
}
out
}
/// A clipping group of `items`, clipped by `c` (their clip at `depth`), or their group when `c` is
/// a plain group.
fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc<Node> {
let mut children = vec![];
if let Some((bp, r)) = &c.region {
let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(bp), Appearance::default());
if let NodeKind::Path { clipping, rule, .. } = &mut path.kind {
*clipping = true;
*rule = *r;
}
children.push(Arc::new(path));
}
children.extend(nest_at(doc, items, depth + 1));
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: c.region.is_some() }))
}
+188
View File
@@ -0,0 +1,188 @@
//! CMYK images: the ink amounts of image blobs that hold CMYK samples.
//!
//! Two encodings hold them: a four-component JPEG, whose samples are stored inverted with Adobe's
//! marker (the convention JPEG readers and PDF writers take a CMYK JPEG to follow), and a CMYK
//! TIFF (8 or 16 bits, chunky, ink amounts as they are). [`ImageBlob::cmyk_tiff`] writes the
//! latter, Deflate-compressed. Every other image decodes to RGB(A) only.
use std::io::Cursor;
use zune_jpeg::zune_core::bytestream::ZCursor;
use zune_jpeg::zune_core::colorspace::ColorSpace;
use zune_jpeg::zune_core::options::DecoderOptions;
use crate::ImageBlob;
/// Most pixels a CMYK image is decoded with (256 MB of samples): larger ones decode as RGB.
pub const MAX_CMYK_PIXELS: u64 = 1 << 26;
/// The ink amounts of a CMYK image: C, M, Y and K bytes per pixel, 0 is no ink.
#[derive(Clone, Debug, PartialEq)]
pub struct Inks {
pub width: u32,
pub height: u32,
pub data: Vec<u8>,
}
impl Inks {
/// `data` as a `width` × `height` image; `None` when it doesn't hold that many pixels, the
/// image is empty or it has more than [`MAX_CMYK_PIXELS`].
pub fn new(width: u32, height: u32, data: Vec<u8>) -> Option<Self> {
let n = pixels(width, height)?;
(data.len() as u64 == n * 4).then_some(Self { width, height, data })
}
}
/// `width` × `height` when it is a CMYK image's size: not empty, at most [`MAX_CMYK_PIXELS`].
fn pixels(width: u32, height: u32) -> Option<u64> {
let n = width as u64 * height as u64;
(n > 0 && n <= MAX_CMYK_PIXELS).then_some(n)
}
/// The number of colour components of JPEG `bytes` (its frame header); `None` when they aren't a
/// JPEG.
pub fn jpeg_components(bytes: &[u8]) -> Option<usize> {
if !bytes.starts_with(&[0xFF, 0xD8]) {
return None;
}
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), DecoderOptions::default());
d.decode_headers().ok()?;
Some(d.info()?.components as usize)
}
fn is_tiff(bytes: &[u8]) -> bool {
bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*")
}
/// The colour type of TIFF `bytes` and its decoder.
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
if !is_tiff(bytes) {
return None;
}
let mut d = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?;
Some((d.colortype().ok()?, d))
}
impl ImageBlob {
/// A CMYK TIFF of ink amounts `inks` (Deflate-compressed); `None` when it can't be written.
pub fn cmyk_tiff(inks: &Inks) -> Option<Self> {
use tiff::encoder::{Compression, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
let mut out = Cursor::new(Vec::new());
TiffEncoder::new(&mut out)
.ok()?
.with_compression(Compression::Deflate(DeflateLevel::Balanced))
.write_image::<CMYK8>(inks.width, inks.height, &inks.data)
.ok()?;
Some(Self::new("image/tiff", out.into_inner()))
}
/// Does the image hold CMYK samples (a four-component JPEG or a CMYK TIFF)? Reads only its
/// header.
pub fn is_cmyk(&self) -> bool {
jpeg_components(&self.bytes) == Some(4) || tiff(&self.bytes).is_some_and(|(t, _)| matches!(t, tiff::ColorType::CMYK(8 | 16)))
}
/// The ink amounts of a CMYK image; `None` for other images, and ones that can't be decoded
/// or are larger than [`MAX_CMYK_PIXELS`].
pub fn cmyk(&self) -> Option<Inks> {
let bytes = self.bytes.as_slice();
if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }
}
}
/// The ink amounts of a four-component JPEG (its samples inverted back; YCCK converted to CMYK
/// first, as JPEG readers do).
fn jpeg_inks(bytes: &[u8]) -> Option<Inks> {
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), DecoderOptions::default());
d.decode_headers().ok()?;
let space = d.input_colorspace()?;
if d.info()?.components != 4 || !matches!(space, ColorSpace::CMYK | ColorSpace::YCCK) {
return None;
}
let (w, h) = d.dimensions()?;
let (w, h) = (u32::try_from(w).ok()?, u32::try_from(h).ok()?);
pixels(w, h)?;
d.set_options(DecoderOptions::default().jpeg_set_out_colorspace(space));
let mut data = d.decode().ok()?;
for p in data.as_chunks_mut::<4>().0 {
if space == ColorSpace::YCCK {
let [y, cb, cr] = [p[0], p[1], p[2]].map(f32::from);
// libjpeg's YCCK → CMYK: the complement of the YCbCr colour's RGB.
p[0] = (434.456 - y - 1.402 * cr).clamp(0.0, 255.0) as u8;
p[1] = (119.541 - y + 0.344 * cb + 0.714 * cr).clamp(0.0, 255.0) as u8;
p[2] = (481.816 - y - 1.772 * cb).clamp(0.0, 255.0) as u8;
}
for v in p.iter_mut() {
*v = 255 - *v;
}
}
Inks::new(w, h, data)
}
/// The ink amounts of a chunky CMYK TIFF (16-bit samples to 8 bits).
fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
let (kind, mut d) = tiff(bytes)?;
let (w, h) = d.dimensions().ok()?;
// Planar samples (one plane per ink) would read as interleaved.
let planar = d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) || planar || pixels(w, h).is_none() {
return None;
}
let data = match d.read_image().ok()? {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
Inks::new(w, h, data)
}
#[cfg(test)]
mod tests {
use super::*;
fn inks() -> Inks {
Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap()
}
#[test]
fn a_cmyk_tiff_keeps_its_ink_amounts_and_shows_as_rgb() {
let b = ImageBlob::cmyk_tiff(&inks()).unwrap();
assert_eq!(b.mime, "image/tiff");
assert!(b.is_cmyk());
assert_eq!(b.cmyk(), Some(inks()), "lossless");
// Every image reader sees an RGB image: cyan is (0, 255, 255).
let rgb = image::load_from_memory(&b.bytes).unwrap().to_rgb8();
assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]);
}
#[test]
fn a_cmyk_jpeg_reads_back_as_ink_amounts() {
// A flat CMYK JPEG: Adobe's inverted samples with its marker.
let flat = [10u8, 40, 100, 20];
let px: Vec<u8> = (0..16 * 16).flat_map(|_| flat).collect();
let mut jpg = Vec::new();
jpeg_encoder::Encoder::new(&mut jpg, 100).encode(&px, 16, 16, jpeg_encoder::ColorType::Cmyk).unwrap();
let b = ImageBlob::new("image/jpeg", jpg);
assert_eq!(jpeg_components(&b.bytes), Some(4));
assert!(b.is_cmyk());
let back = b.cmyk().unwrap();
assert_eq!((back.width, back.height), (16, 16));
for p in back.data.as_chunks::<4>().0 {
assert!(p.iter().zip(flat).all(|(a, b)| a.abs_diff(b) <= 2), "{p:?}");
}
}
#[test]
fn other_images_are_not_cmyk() {
let mut png = Vec::new();
image::RgbaImage::new(2, 2).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut jpg = Vec::new();
image::RgbImage::new(8, 8).write_to(&mut Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap();
for b in [ImageBlob::png(png), ImageBlob::new("image/jpeg", jpg), ImageBlob::new("image/tiff", b"II*\0junk".to_vec())] {
assert!(!b.is_cmyk() && b.cmyk().is_none(), "{}", b.mime);
}
assert_eq!(Inks::new(2, 2, vec![0; 15]), None, "too few samples");
assert_eq!(Inks::new(0, 2, vec![]), None, "empty");
assert_eq!(Inks::new(1 << 14, 1 << 13, vec![]), None, "too large");
}
}
+318 -32
View File
@@ -20,19 +20,24 @@ pub struct Hit {
pub leaf: NodeId,
pub ancestry: Vec<NodeId>,
pub kind: HitKind,
/// The innermost envelope in the ancestry whose contents are being edited (Edit Contents):
/// its content, not the envelope, is the object clicked.
pub contents_of: Option<NodeId>,
/// How many entries at the start of `ancestry` are layers (the layer and its sublayers).
pub layers: usize,
}
impl Hit {
/// The object the Selection tool selects: the child of the layer (the outermost group).
/// Inside isolation mode (`scope`), the child of the isolated container instead.
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
if let Some(s) = scope
&& let Some(i) = self.ancestry.iter().position(|x| *x == s)
{
// The innermost of the isolated container and an envelope whose contents are edited.
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
if let Some(i) = at(scope).max(at(self.contents_of)) {
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
}
// Skip layers and sublayers.
self.ancestry.get(1).copied().unwrap_or(self.leaf)
// Skip layers and sublayers: they are not objects.
self.ancestry.get(self.layers.max(1)).copied().unwrap_or(self.leaf)
}
}
@@ -45,22 +50,45 @@ pub struct HitOptions {
pub outline: bool,
/// "Object Selection by Path Only" preference.
pub path_only: bool,
/// "Type Object Selection by Path Only" preference: type is picked on its type path only
/// (point type's baseline, area type's frame, type on a path's path), not anywhere in its
/// bounds.
pub type_path_only: bool,
}
impl Default for HitOptions {
fn default() -> Self {
Self { tol: 3.0, outline: false, path_only: false }
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false }
}
}
/// Topmost editable object under `p`.
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
hit_test_skipping(doc, p, opt, &|_| false)
}
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
let mut tops: Vec<NodeId> = vec![];
// Each round leaves out the objects found so far (and everything inside them).
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id)).map(|h| h.top_object(scope)) {
if tops.contains(&top) {
break;
}
tops.push(top);
}
tops
}
/// [`hit_test`] passing over the nodes `skip` names (and their contents).
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool) -> Option<Hit> {
let mut chain = Vec::new();
for layer in doc.layers.iter().rev() {
if !layer.visible || layer.locked {
continue;
}
if let NodeKind::Layer { template: true, .. } = layer.kind {
if layer.is_template() {
continue;
}
// Pattern editing mode: only the tile art is editable.
@@ -68,7 +96,10 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
continue;
}
chain.push(layer.id);
if let Some(h) = hit_children(layer, p, opt, &mut chain) {
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
if let Some(mut h) = hit_children(layer, p, opt, skip, &mut chain) {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
return Some(h);
}
chain.pop();
@@ -76,29 +107,54 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
None
}
fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId>) -> Option<Hit> {
/// Is `n` an envelope whose contents are being edited (they hit, not the envelope)?
fn edits_contents(n: &Node) -> bool {
matches!(n.kind, NodeKind::Envelope { editing: true, .. })
}
/// 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, skip: &dyn Fn(NodeId) -> bool, 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;
}
for c in children.iter().rev() {
if !c.visible || c.locked {
// Hidden, locked and template sublayers block clicks like top-level layers do.
if !c.visible || c.locked || c.is_template() || skip(c.id) {
continue;
}
if let Some(b) = c.visual_bounds()
// (An envelope's content sits where it was, not where the envelope draws it.)
if !edits_contents(c)
&& let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
continue;
}
chain.push(c.id);
let hit = match &c.kind {
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }),
// A sublayer whose Preview is off is clicked in outline, like an outline view.
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, skip, chain),
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, skip, chain),
// Edit Contents: the envelope's content hits, undistorted.
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, skip, chain),
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
// as the blend.
NodeKind::Blend { .. } => hit_children(c, p, opt, skip, chain)
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
};
if hit.is_some() {
return hit;
@@ -108,13 +164,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,17 +187,17 @@ 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());
}
NodeKind::Compound { rule, .. } => {
let bp = n.stroke_path()?;
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
return Some(k);
}
if stroke_contains(&bp, n.appearance.stroke_width(), opt.tol, p) {
return Some(HitKind::Stroke);
}
(!opt.outline && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill)
(!opt.outline && !opt.path_only && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill)
}
// The type path lies within the bounds: they rule out the rest before it is measured.
NodeKind::Text(t) if opt.type_path_only => {
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?;
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
}
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
@@ -149,6 +214,28 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
}
}
/// Is `p` within `tol` of the type path of `t` (in `n`), what "Type Object Selection by Path
/// Only" picks type by: its baselines ([`crate::TextObject::baselines`]), area type's frame and
/// type on a path's path, drawn through `n`'s perspective projection if it has one.
fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool {
use crate::TextKind;
use vectorcraft_geom::kurbo::ParamCurveNearest;
let h = n.projection();
let project = |q: Point| h.as_ref().and_then(|h| h.apply(q)).unwrap_or(q);
// A homography keeps straight lines straight: a baseline's ends are enough.
let near = |(a, b): (Point, Point)| vectorcraft_geom::Line::new(project(t.xf * a), project(t.xf * b)).nearest(p, 1e-9).distance_sq <= tol * tol;
if t.baselines().any(near) {
return true;
}
let (TextKind::Area { frame: path } | TextKind::OnPath { path, .. }) = &t.kind else { return false };
let mut path = path.transformed(t.xf);
if h.is_some() {
let piece = path.bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25));
path = crate::live::map_nonlinear(&path, piece, project);
}
stroke_contains(&path.to_bezpath(), 0.0, tol, p)
}
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
match &n.kind {
@@ -162,13 +249,28 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
let mut out = Vec::new();
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
// The objects of the layers, looking through sublayers (hidden, locked and template ones
// are left out).
None => doc
.layers
.iter()
.filter(|l| l.visible && !l.locked && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
.flat_map(|l| l.children().into_iter().flatten())
.filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
.flat_map(|l| layer_objects(l))
.collect(),
};
fn layer_objects(l: &Node) -> Vec<&std::sync::Arc<Node>> {
let mut out = vec![];
for c in l.children().into_iter().flatten() {
if c.is_layer() {
if c.visible && !c.locked && !c.is_template() {
out.extend(layer_objects(c));
}
} else {
out.push(c);
}
}
out
}
fn touches(n: &Node, r: Rect) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
@@ -266,6 +368,190 @@ mod tests {
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
}
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
fn sublayers_are_not_objects() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let sub = d.alloc_id();
d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap();
let inner = d.alloc_id();
d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap();
let a = d.alloc_id();
d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let g = d.alloc_id();
let b = d.alloc_id();
let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art());
d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap();
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3));
let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap();
assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b));
let all = Rect::new(-5.0, -5.0, 200.0, 200.0);
assert_eq!(marquee(&d, all, None, false), vec![a, g]);
assert_eq!(d.layer_containing(a), Some(inner));
assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour");
assert_eq!(d.selectable_art(), vec![a, g]);
for f in [
|n: &mut Node| n.visible = false,
|n: &mut Node| n.locked = true,
|n: &mut Node| {
if let NodeKind::Layer { template, .. } = &mut n.kind {
*template = true;
}
},
] {
let mut d2 = d.clone();
f(d2.node_mut(inner).unwrap());
assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none());
assert_eq!(marquee(&d2, all, None, false), vec![g]);
assert_eq!(d2.selectable_art(), vec![g]);
}
// A sublayer whose Preview is off is clicked in outline: its fill no longer hits.
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind {
*preview = false;
}
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some());
}
/// Object Selection by Path Only: a filled path or compound path hits on its outline only.
#[test]
fn path_only_leaves_out_fills() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (a, c) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default());
let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero });
compound.appearance = Appearance::default_art();
d.insert(Some(l), 9, compound).unwrap();
let path_only = HitOptions { path_only: true, ..Default::default() };
for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] {
assert_eq!(hit_test(&d, inside, HitOptions::default()).map(|h| h.leaf), Some(id), "the fill hits");
assert!(hit_test(&d, inside, path_only).is_none(), "path only: the fill of {id:?} doesn't hit");
assert_eq!(hit_test(&d, edge, path_only).map(|h| h.leaf), Some(id), "path only: the outline does");
}
}
/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its
/// frame and type on a path on its path, no longer anywhere in its bounds.
#[test]
fn type_path_only_picks_type_on_its_path() {
use crate::{CharStyle, TextKind, TextObject};
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
let mut add = |t: TextObject| {
let id = d.alloc_id();
d.insert(Some(l), 9, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
id
};
// Point type at (10, 100), 20 pt: its glyphs rise above the baseline, its layout is 60 wide.
let mut point = TextObject::point(Point::new(10.0, 100.0), "Hello", CharStyle { size: 20.0, ..CharStyle::default() });
point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0));
let point = add(point);
let mut area = TextObject::point(Point::new(200.0, 200.0), "Area", CharStyle::default());
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) };
let area = add(area);
let mut on_path = TextObject::point(Point::new(10.0, 300.0), "Path", CharStyle::default());
on_path.kind = TextKind::OnPath { path: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)), start: 0.0, end: None };
let on_path = add(on_path);
let path_only = HitOptions { type_path_only: true, ..Default::default() };
let hit = |p: Point, opt: HitOptions| hit_test(&d, p, opt).map(|h| (h.leaf, h.kind));
// Off: anywhere in the bounds.
assert_eq!(hit(Point::new(40.0, 90.0), HitOptions::default()), Some((point, HitKind::Bounds)));
assert_eq!(hit(Point::new(250.0, 225.0), HitOptions::default()), Some((area, HitKind::Bounds)));
assert_eq!(hit(Point::new(60.0, 320.0), HitOptions::default()), Some((on_path, HitKind::Bounds)));
// On: the glyphs and the inside of the frame or path no longer pick the type.
assert_eq!(hit(Point::new(40.0, 90.0), path_only), None, "point type: among its glyphs");
assert_eq!(hit(Point::new(250.0, 225.0), path_only), None, "area type: inside its frame");
assert_eq!(hit(Point::new(60.0, 320.0), path_only), None, "type on a path: inside its path");
// The baseline (within the tolerance, across the layout's width), frame and path do.
assert_eq!(hit(Point::new(40.0, 101.5), path_only), Some((point, HitKind::Outline)), "point type: its baseline");
assert_eq!(hit(Point::new(75.0, 100.0), path_only), None, "point type: past the end of its layout");
assert_eq!(hit(Point::new(250.0, 200.5), path_only), Some((area, HitKind::Outline)), "area type: its frame");
assert_eq!(hit(Point::new(60.0, 339.5), path_only), Some((on_path, HitKind::Outline)), "type on a path: its path");
}
/// Type Object Selection by Path Only with the layout's baselines: every line's for point type,
/// area type's besides its frame, a vertical column's centre line, and type in perspective
/// where it is drawn.
#[test]
fn type_path_only_picks_every_baseline() {
use crate::{CharStyle, PerspectiveAttachment, TextKind, TextObject};
use vectorcraft_geom::{Affine, Homography};
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
let mut add = |t: TextObject, perspective: Option<Affine>| {
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
n.perspective = perspective.map(|a| {
Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) })
});
let id = n.id;
d.insert(Some(l), 9, n).unwrap();
id
};
let style = CharStyle { size: 20.0, ..CharStyle::default() };
// Two lines of point type at (10, 100), 24 pt apart.
let mut point = TextObject::point(
Point::new(10.0, 100.0),
"Hello
world",
style.clone(),
);
point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 28.0));
point.cached_baselines = vec![(Point::ZERO, Point::new(60.0, 0.0)), (Point::new(0.0, 24.0), Point::new(50.0, 24.0))];
let point = add(point, None);
// Area type: a line inside its frame.
let mut area = TextObject::point(Point::new(200.0, 50.0), "Area", style.clone());
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) };
area.cached_baselines = vec![(Point::new(0.0, 16.0), Point::new(40.0, 16.0))];
let area = add(area, None);
// Vertical point type at (300, 200) with no baselines cached: down its first column.
let mut vertical = TextObject::point(Point::new(300.0, 200.0), "Tate", style.clone());
vertical.vertical = true;
vertical.cached_bounds = Some(Rect::new(-10.0, 0.0, 10.0, 80.0));
let vertical = add(vertical, None);
// Point type drawn 100 to the right of where it is laid out.
let mut moved = TextObject::point(Point::new(10.0, 350.0), "Moved", style);
moved.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0));
let moved = add(moved, Some(Affine::translate((100.0, 0.0))));
let path_only = HitOptions { type_path_only: true, ..Default::default() };
let hit = |p: Point| hit_test(&d, p, path_only).map(|h| h.leaf);
assert_eq!(hit(Point::new(40.0, 100.0)), Some(point), "the first line's baseline");
assert_eq!(hit(Point::new(40.0, 125.0)), Some(point), "the second line's");
assert_eq!(hit(Point::new(40.0, 112.0)), None, "between the lines");
assert_eq!(hit(Point::new(65.0, 124.0)), None, "past the end of the shorter second line");
assert_eq!(hit(Point::new(220.0, 66.0)), Some(area), "area type: a line's baseline");
assert_eq!(hit(Point::new(220.0, 80.0)), None, "area type: inside the frame, off the baselines");
assert_eq!(hit(Point::new(299.0, 260.0)), Some(vertical), "vertical type: down the column");
assert_eq!(hit(Point::new(306.0, 260.0)), None, "vertical type: beside it");
assert_eq!(hit(Point::new(140.0, 351.0)), Some(moved), "in perspective: where it is drawn");
assert_eq!(hit(Point::new(40.0, 351.0)), None, "in perspective: not where it is laid out");
}
/// `objects_at`: every object under the point, topmost first; a group counts once.
#[test]
fn objects_at_lists_the_stack() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let rect =
|d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
let a = rect(&mut d, 0.0);
let (b, c) = (rect(&mut d, 10.0), rect(&mut d, 20.0));
let (a_id, b_id, c_id, g) = (a.id, b.id, c.id, d.alloc_id());
d.insert(Some(l), 0, a).unwrap();
d.insert(Some(l), 9, Node::group(g, vec![Arc::new(b), Arc::new(c)])).unwrap();
let far = rect(&mut d, 120.0);
d.insert(Some(l), 9, far).unwrap();
let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope);
assert_eq!(at(30.0, None), vec![g, a_id]);
assert_eq!(at(30.0, Some(g))[..2], [c_id, b_id], "isolated: the group's own objects");
assert_eq!(at(5.0, None), vec![a_id]);
assert!(at(90.0, None).is_empty());
}
#[test]
fn locked_and_hidden_skip() {
let mut d = Document::new(200.0, 200.0);
Loaded 100 of 1011 files, more files were not shown because too many files have changed in this diff. Show more