Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d14f337db | ||
|
|
89ea46c805 | ||
|
|
ffd31fd9cf | ||
|
|
e63606826a | ||
|
|
71371b9d58 | ||
|
|
5db200d392 | ||
|
|
c0396c0761 | ||
|
|
c7f1e00955 | ||
|
|
3c91ca8a1d | ||
|
|
7e3bcc48fe |
No files matched your search
+8
-8
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-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), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking intLine truncated
|
||||
_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), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking intLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -42,7 +42,7 @@ since 2026-10-01.
|
||||
| **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 on Windows and macOS (#306), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); 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) |
|
||||
| **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 on Windows and macOS (#306), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); 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 Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); 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) |
|
||||
|
||||
@@ -121,7 +121,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **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).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
@@ -154,9 +154,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **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).
|
||||
- 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; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; 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).
|
||||
- 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); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
@@ -167,7 +167,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| 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 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; 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 |
|
||||
@@ -199,7 +199,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Area | Weight | Done | Missing (main items) | One agent (h) |
|
||||
|---|---:|---:|---|---:|
|
||||
| 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 |
|
||||
| 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 corners between curved sides (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 |
|
||||
@@ -238,7 +238,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form
|
||||
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, 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.
|
||||
- **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). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). 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.
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ mod window;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp};
|
||||
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
|
||||
|
||||
struct App {
|
||||
app: VectorcraftApp,
|
||||
@@ -256,6 +256,10 @@ fn services() -> Services {
|
||||
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()),
|
||||
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
|
||||
// owner that never answers holds a read for up to 4 s. Windows only asks which formats the
|
||||
// clipboard holds and macOS asks the pasteboard server, so they check in line.
|
||||
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory),
|
||||
// Help → Discord / website / GitHub, the Discord button, About and Home links.
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
let _ = webbrowser::open(url);
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Live Corners on any path: the corners of its uncut outline (a live rectangle's or polygon's, a
|
||||
//! live path's, or a plain path's own), each one's radius and kind, and the anchors of the path as
|
||||
//! drawn that belong to each. [`set_corners`] edits them.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use vectorcraft_geom::corners::{Corner, cut_corners, path_corners};
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect};
|
||||
|
||||
use crate::node::{LiveShape, Node, NodeKind};
|
||||
use crate::selection::AnchorRef;
|
||||
|
||||
/// The Live Corners of a path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LiveCorners<'a> {
|
||||
/// The path with its corners uncut, in the corners' own space: a live rectangle's (its size
|
||||
/// and radii in document units, see [`LiveShape::folded`]), else the document's.
|
||||
pub base: Cow<'a, PathData>,
|
||||
/// Maps `base` into the document.
|
||||
pub xf: Affine,
|
||||
/// The corners that can be cut, in anchor order ([`path_corners`] of `base`).
|
||||
pub corners: Vec<Corner>,
|
||||
radii: Vec<f64>,
|
||||
kinds: Vec<CornerKind>,
|
||||
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
|
||||
sources: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl<'a> LiveCorners<'a> {
|
||||
/// The Live Corners of `path` and its live shape; `None` for shapes without corners (ellipses,
|
||||
/// lines).
|
||||
pub fn new(path: &'a PathData, live: Option<&'a LiveShape>) -> Option<Self> {
|
||||
let (base, xf, radii, kinds) = match live {
|
||||
None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![]),
|
||||
Some(l @ LiveShape::Rectangle { .. }) => {
|
||||
let LiveShape::Rectangle { w, h, radii, kinds, xf } = l.folded() else { return None };
|
||||
(Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec())
|
||||
}
|
||||
Some(l @ LiveShape::Polygon { radii, kinds, .. }) => (Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone()),
|
||||
Some(LiveShape::Path { base, radii, kinds }) => (Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone()),
|
||||
Some(LiveShape::Ellipse { .. } | LiveShape::Line { .. }) => return None,
|
||||
};
|
||||
let sources = if radii.iter().any(|r| *r > 0.0) {
|
||||
cut_corners(&base, &radii, &kinds).1
|
||||
} else {
|
||||
let mut first = 0;
|
||||
base.subpaths
|
||||
.iter()
|
||||
.map(|sp| {
|
||||
first += sp.anchors.len();
|
||||
(first - sp.anchors.len()..first).collect()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Some(Self { corners: path_corners(&base), base, xf, radii, kinds, sources })
|
||||
}
|
||||
|
||||
/// The Live Corners of a path object.
|
||||
pub fn of(node: &'a Node) -> Option<Self> {
|
||||
let NodeKind::Path { path, live, .. } = &node.kind else { return None };
|
||||
Self::new(path, live.as_ref())
|
||||
}
|
||||
|
||||
/// The corner at anchor `index` of `base`.
|
||||
pub fn corner(&self, index: usize) -> Option<&Corner> {
|
||||
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.corners.get(i))
|
||||
}
|
||||
|
||||
/// The radius corner `index` is set to, in document units (drawn no larger than fits).
|
||||
pub fn radius(&self, index: usize) -> f64 {
|
||||
self.radii.get(index).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The kind corner `index` is cut with.
|
||||
pub fn kind(&self, index: usize) -> CornerKind {
|
||||
self.kinds.get(index).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every corner.
|
||||
pub fn all(&self) -> BTreeSet<usize> {
|
||||
self.corners.iter().map(|c| c.index).collect()
|
||||
}
|
||||
|
||||
/// The corners holding one of `anchors` (of the path as drawn).
|
||||
pub fn corners_of(&self, anchors: &BTreeSet<AnchorRef>) -> BTreeSet<usize> {
|
||||
anchors.iter().filter_map(|(si, ai)| self.sources.get(*si)?.get(*ai).copied()).filter(|k| self.corner(*k).is_some()).collect()
|
||||
}
|
||||
|
||||
/// The corners Live Corners edit for a selection: those holding a selected anchor when the
|
||||
/// path is partly selected (Direct Selection), else every corner.
|
||||
pub fn picked(&self, partial: Option<&BTreeSet<AnchorRef>>) -> BTreeSet<usize> {
|
||||
let picked = partial.map(|a| self.corners_of(a)).unwrap_or_default();
|
||||
if picked.is_empty() { self.all() } else { picked }
|
||||
}
|
||||
|
||||
/// The anchors of the path as drawn that belong to `corners` (to keep them selected as the
|
||||
/// path changes).
|
||||
pub fn anchors_of(&self, corners: &BTreeSet<usize>) -> BTreeSet<AnchorRef> {
|
||||
let mut out = BTreeSet::new();
|
||||
for (si, from) in self.sources.iter().enumerate() {
|
||||
out.extend(from.iter().enumerate().filter(|(_, k)| corners.contains(k)).map(|(ai, _)| (si, ai)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
|
||||
pub fn sources(&self) -> &[Vec<usize>] {
|
||||
&self.sources
|
||||
}
|
||||
|
||||
/// The radius (document units) and kind `corners` share (each `None` when they differ, or
|
||||
/// for no corners).
|
||||
pub fn style(&self, corners: &BTreeSet<usize>) -> (Option<f64>, Option<CornerKind>) {
|
||||
fn shared<T: Copy>(mut it: impl Iterator<Item = T>, same: impl Fn(T, T) -> bool) -> Option<T> {
|
||||
let first = it.next()?;
|
||||
it.all(|x| same(x, first)).then_some(first)
|
||||
}
|
||||
let radius = shared(corners.iter().map(|k| self.radius(*k)), |a, b| (a - b).abs() < 1e-9);
|
||||
(radius, shared(corners.iter().map(|k| self.kind(*k)), |a, b| a == b))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `radius` (document units; negative: 0) and/or `kind` on the `corners` (anchor indices of
|
||||
/// the uncut outline, see [`LiveCorners`]) of a path and its live shape, then cut the path again.
|
||||
/// A path that isn't a live shape keeps its uncut outline as a [`LiveShape::Path`], which is a
|
||||
/// plain path again once no corner is cut. Ellipses and lines have no corners: they stay as they
|
||||
/// are.
|
||||
pub fn set_corners(path: &mut PathData, live: &mut Option<LiveShape>, corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
|
||||
fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
|
||||
for k in corners {
|
||||
if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) {
|
||||
*slot = r.max(0.0);
|
||||
}
|
||||
if let (Some(kind), Some(slot)) = (kind, kinds.get_mut(*k)) {
|
||||
*slot = kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One radius and kind per corner of an outline of `n` anchors while editing; none saved
|
||||
/// when all are sharp or round.
|
||||
fn edit_vec(
|
||||
n: usize,
|
||||
radii: &mut Vec<f64>,
|
||||
kinds: &mut Vec<CornerKind>,
|
||||
corners: &BTreeSet<usize>,
|
||||
radius: Option<f64>,
|
||||
kind: Option<CornerKind>,
|
||||
) {
|
||||
radii.resize(n, 0.0);
|
||||
kinds.resize(n, CornerKind::Round);
|
||||
edit(radii, kinds, corners, radius, kind);
|
||||
if radii.iter().all(|r| *r <= 0.0) {
|
||||
radii.clear();
|
||||
}
|
||||
if kinds.iter().all(|k| *k == CornerKind::Round) {
|
||||
kinds.clear();
|
||||
}
|
||||
}
|
||||
let l = live.get_or_insert_with(|| LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] });
|
||||
// Radii are document lengths and corners circular, also on a rectangle from a file that kept
|
||||
// an uneven scale in its transform (#442).
|
||||
l.fold_scale();
|
||||
match l {
|
||||
LiveShape::Rectangle { radii, kinds, .. } => edit(radii, kinds, corners, radius, kind),
|
||||
LiveShape::Polygon { sides, radii, kinds, .. } => edit_vec(*sides as usize, radii, kinds, corners, radius, kind),
|
||||
LiveShape::Path { base, radii, kinds } => edit_vec(base.anchor_count(), radii, kinds, corners, radius, kind),
|
||||
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
|
||||
}
|
||||
match live {
|
||||
Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => {
|
||||
*path = std::mem::take(base);
|
||||
*live = None;
|
||||
}
|
||||
Some(l) => *path = l.to_path(),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::{Point, SubPath};
|
||||
|
||||
fn set(v: &[usize]) -> BTreeSet<usize> {
|
||||
v.iter().copied().collect()
|
||||
}
|
||||
|
||||
fn anchors(v: &[usize]) -> BTreeSet<AnchorRef> {
|
||||
v.iter().map(|ai| (0, *ai)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_corners_map_to_their_anchors() {
|
||||
let l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
|
||||
let path = l.to_path();
|
||||
let c = LiveCorners::new(&path, Some(&l)).unwrap();
|
||||
// Anchors: top-left, the top-right corner's two, bottom-right, bottom-left.
|
||||
assert_eq!(c.sources(), [vec![0, 1, 1, 2, 3]]);
|
||||
assert_eq!(path.anchor_count(), 5);
|
||||
assert_eq!(c.corners_of(&anchors(&[2, 4])), set(&[1, 3]));
|
||||
assert!(c.corners_of(&anchors(&[9])).is_empty(), "out of range");
|
||||
assert_eq!(c.anchors_of(&set(&[1, 3])), anchors(&[1, 2, 4]));
|
||||
// Partly selected: the corners of the selected anchors; wholly (or no anchor): all four.
|
||||
assert_eq!(c.picked(Some(&anchors(&[0]))), set(&[0]));
|
||||
assert_eq!(c.picked(None), set(&[0, 1, 2, 3]));
|
||||
assert_eq!(c.picked(Some(&anchors(&[]))), set(&[0, 1, 2, 3]));
|
||||
assert_eq!(c.style(&set(&[1])), (Some(10.0), Some(CornerKind::Round)));
|
||||
assert_eq!(c.style(&set(&[0, 1])), (None, Some(CornerKind::Round)));
|
||||
assert_eq!(c.style(&set(&[0, 2, 3])), (Some(0.0), Some(CornerKind::Round)));
|
||||
let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY };
|
||||
assert!(LiveCorners::new(&e.to_path(), Some(&e)).is_none());
|
||||
}
|
||||
|
||||
/// #511: a plain path (a star) rounds its corners and keeps them editable; with none cut it
|
||||
/// is plain again.
|
||||
#[test]
|
||||
fn a_plain_path_keeps_its_cut_corners_live() {
|
||||
let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0);
|
||||
let (mut path, mut live) = (star.clone(), None);
|
||||
set_corners(&mut path, &mut live, &set(&[0, 2]), Some(5.0), None);
|
||||
let Some(LiveShape::Path { base, radii, kinds }) = &live else { panic!("live corners: {live:?}") };
|
||||
assert_eq!((base, radii.len(), kinds.len()), (&star, 10, 0));
|
||||
assert_eq!(path.anchor_count(), 12, "two corners cut");
|
||||
let c = LiveCorners::new(&path, live.as_ref()).unwrap();
|
||||
assert_eq!(c.style(&set(&[0, 2])), (Some(5.0), Some(CornerKind::Round)));
|
||||
assert_eq!(c.corners_of(&anchors(&[0, 11])), set(&[0]), "the first corner's cut ends the path");
|
||||
// Kinds alone keep it live; squaring every corner makes it the plain star again.
|
||||
set_corners(&mut path, &mut live, &set(&[0, 2]), Some(0.0), Some(CornerKind::Chamfer));
|
||||
assert!(matches!(&live, Some(LiveShape::Path { radii, .. }) if radii.is_empty()));
|
||||
set_corners(&mut path, &mut live, &set(&[0, 2]), None, Some(CornerKind::Round));
|
||||
assert_eq!((path, live), (star, None));
|
||||
}
|
||||
|
||||
/// A polygon's corners are cut in the document and survive a change of sides when shared.
|
||||
#[test]
|
||||
fn polygon_corners_follow_their_sides() {
|
||||
let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![] };
|
||||
let (mut path, mut live) = (l.to_path(), Some(l.clone()));
|
||||
set_corners(&mut path, &mut live, &set(&[0, 1, 2, 3, 4, 5]), Some(8.0), None);
|
||||
assert_eq!(path.anchor_count(), 12);
|
||||
l = live.clone().unwrap();
|
||||
l.set_sides(8);
|
||||
assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == vec![8.0; 8]));
|
||||
assert_eq!(l.to_path().anchor_count(), 16);
|
||||
// Mixed radii: the new corners are sharp.
|
||||
set_corners(&mut path, &mut live, &set(&[1]), Some(3.0), None);
|
||||
let mut l = live.unwrap();
|
||||
l.set_sides(7);
|
||||
assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == [8.0, 3.0, 8.0, 8.0, 8.0, 8.0, 0.0]));
|
||||
// An uneven scale keeps the cuts circular: the radius scales by the mean scale.
|
||||
let mut p = l.clone();
|
||||
assert!(p.transform(Affine::scale_non_uniform(4.0, 1.0)));
|
||||
assert!(matches!(&p, LiveShape::Polygon { radii, .. } if (radii[0] - 16.0).abs() < 1e-9));
|
||||
assert!(!p.transform(Affine::translate((5.0, 0.0))), "a move needs no new cuts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_paths_and_curves_keep_their_ends_and_smooth_anchors() {
|
||||
let pen = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], false));
|
||||
let c = LiveCorners::new(&pen, None).unwrap();
|
||||
assert_eq!(c.all(), set(&[1]));
|
||||
assert_eq!(c.picked(Some(&anchors(&[0]))), set(&[1]), "an end is no corner: every corner");
|
||||
let (mut path, mut live) = (pen.clone(), None);
|
||||
set_corners(&mut path, &mut live, &c.all(), Some(10.0), None);
|
||||
assert_eq!(path.anchor_count(), 4);
|
||||
assert_eq!(path.subpaths[0].anchors[0].p, Point::new(0.0, 0.0));
|
||||
assert_eq!(path.subpaths[0].anchors[3].p, Point::new(100.0, 80.0));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod assets;
|
||||
pub mod blend;
|
||||
pub mod clipnest;
|
||||
pub mod cmyk;
|
||||
pub mod corners;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
pub mod inks;
|
||||
@@ -58,6 +59,7 @@ pub use appearance::{
|
||||
StrokeLayer, WidthProfile,
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use graph::{GraphKind, GraphSpec};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
|
||||
+102
-73
@@ -1,17 +1,16 @@
|
||||
//! Document nodes.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_geom::corners::cut_corners;
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
|
||||
use crate::pattern::RepeatSpec;
|
||||
use crate::selection::AnchorRef;
|
||||
use crate::text::TextObject;
|
||||
|
||||
/// Stable per-document object id. Never reused.
|
||||
@@ -104,11 +103,30 @@ pub enum LiveShape {
|
||||
radius: f64,
|
||||
sides: u32,
|
||||
xf: Affine,
|
||||
/// Corner radii in document units, by vertex from the first one clockwise (none: sharp).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
radii: Vec<f64>,
|
||||
/// Corner kinds, in the same order (none: round).
|
||||
#[serde(default, skip_serializing_if = "all_round")]
|
||||
kinds: Vec<CornerKind>,
|
||||
},
|
||||
Line {
|
||||
a: Point,
|
||||
b: Point,
|
||||
},
|
||||
/// Any other path whose corners Live Corners cut (a star, a pen path): it keeps its uncut
|
||||
/// outline so the corners stay editable. Without a cut corner it is a plain path again.
|
||||
Path {
|
||||
/// The path with its corners uncut, in the document.
|
||||
base: PathData,
|
||||
/// Corner radii in document units, by anchor index of `base` (counting every subpath's
|
||||
/// anchors in order; missing: sharp).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
radii: Vec<f64>,
|
||||
/// Corner kinds, in the same order (missing: round).
|
||||
#[serde(default, skip_serializing_if = "all_round")]
|
||||
kinds: Vec<CornerKind>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LiveShape {
|
||||
@@ -119,6 +137,7 @@ impl LiveShape {
|
||||
LiveShape::Ellipse { .. } => "Ellipse",
|
||||
LiveShape::Polygon { .. } => "Polygon",
|
||||
LiveShape::Line { .. } => "Line",
|
||||
LiveShape::Path { .. } => "Path",
|
||||
}
|
||||
}
|
||||
/// Regenerate the path.
|
||||
@@ -128,10 +147,33 @@ impl LiveShape {
|
||||
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf)
|
||||
}
|
||||
LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf),
|
||||
LiveShape::Polygon { radius, sides, xf } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf),
|
||||
LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0,
|
||||
LiveShape::Line { a, b } => shapes::line(*a, *b),
|
||||
LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0,
|
||||
}
|
||||
}
|
||||
/// A polygon's outline with its corners uncut, in the document (empty for other shapes).
|
||||
pub fn polygon_outline(&self) -> PathData {
|
||||
match self {
|
||||
LiveShape::Polygon { radius, sides, xf, .. } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf),
|
||||
_ => PathData::default(),
|
||||
}
|
||||
}
|
||||
/// Give a polygon `n` sides (3–1000). Its corners keep the radius and kind they shared; else
|
||||
/// the corners past the old last one are sharp.
|
||||
pub fn set_sides(&mut self, n: u64) {
|
||||
let LiveShape::Polygon { sides, radii, kinds, .. } = self else { return };
|
||||
*sides = n.clamp(3, 1000) as u32;
|
||||
fn spread<T: Copy + PartialEq + Default>(v: &mut Vec<T>, n: usize) {
|
||||
match v.first().copied() {
|
||||
Some(x) if v.iter().all(|y| *y == x) => *v = vec![x; n],
|
||||
Some(_) => v.resize(n, T::default()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
spread(radii, *sides as usize);
|
||||
spread(kinds, *sides as usize);
|
||||
}
|
||||
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
|
||||
/// the caller must regenerate the path with [`LiveShape::to_path`].
|
||||
///
|
||||
@@ -140,19 +182,27 @@ impl LiveShape {
|
||||
/// only the rotation, reflection and position. The radii scale by the mean scale (the square
|
||||
/// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off.
|
||||
/// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a
|
||||
/// scale in `xf` keep their exact geometry until they're next transformed).
|
||||
/// scale in `xf` keep their exact geometry until they're next transformed). A polygon's or a
|
||||
/// path's corner radii are document lengths: they scale by the mean scale and the corners are
|
||||
/// cut again, circular whatever `a` does.
|
||||
pub fn transform(&mut self, a: Affine) -> bool {
|
||||
match self {
|
||||
LiveShape::Rectangle { xf, .. } => {
|
||||
*xf = a * *xf;
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
|
||||
!moves_only && self.fold_scale()
|
||||
!moves_only(a) && self.fold_scale()
|
||||
}
|
||||
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
|
||||
LiveShape::Ellipse { xf, .. } => {
|
||||
*xf = a * *xf;
|
||||
false
|
||||
}
|
||||
LiveShape::Polygon { xf, radii, .. } => {
|
||||
*xf = a * *xf;
|
||||
scale_radii(radii, a)
|
||||
}
|
||||
LiveShape::Path { base, radii, .. } => {
|
||||
base.transform(a);
|
||||
scale_radii(radii, a)
|
||||
}
|
||||
LiveShape::Line { a: p, b } => {
|
||||
*p = a * *p;
|
||||
*b = a * *b;
|
||||
@@ -192,52 +242,16 @@ impl LiveShape {
|
||||
/// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners
|
||||
/// keep their size (Scale Corners off). False when nothing changed.
|
||||
pub fn keep_corners(&mut self, k: f64) -> bool {
|
||||
match self {
|
||||
LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) && k > 1e-12 => {
|
||||
radii.iter_mut().for_each(|r| *r /= k);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
let radii: &mut [f64] = match self {
|
||||
LiveShape::Rectangle { radii, .. } => radii,
|
||||
LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii,
|
||||
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false,
|
||||
};
|
||||
if !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/// The corner (radii order) of each anchor of a rectangle's path; empty for other shapes.
|
||||
pub fn anchor_corners(&self) -> Vec<usize> {
|
||||
match self {
|
||||
LiveShape::Rectangle { w, h, radii, .. } => shapes::rectangle_anchor_corners(Rect::new(0.0, 0.0, *w, *h), *radii),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
/// The corners of a rectangle that hold one of `anchors` (of its only subpath).
|
||||
pub fn corners_of(&self, anchors: &BTreeSet<AnchorRef>) -> [bool; 4] {
|
||||
let map = self.anchor_corners();
|
||||
let mut out = [false; 4];
|
||||
for k in anchors.iter().filter(|(si, _)| *si == 0).filter_map(|(_, ai)| map.get(*ai)) {
|
||||
if let Some(c) = out.get_mut(*k) {
|
||||
*c = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The corners Live Corners edit for a selection: those holding a selected anchor when the
|
||||
/// shape is partly selected (Direct Selection), else all four.
|
||||
pub fn picked_corners(&self, partial: Option<&BTreeSet<AnchorRef>>) -> [bool; 4] {
|
||||
let picked = partial.map_or([true; 4], |a| self.corners_of(a));
|
||||
if picked.contains(&true) { picked } else { [true; 4] }
|
||||
}
|
||||
/// The anchors of a rectangle's `corners` (to keep them selected as its path changes).
|
||||
pub fn corner_anchors(&self, corners: [bool; 4]) -> BTreeSet<AnchorRef> {
|
||||
self.anchor_corners().into_iter().enumerate().filter(|(_, k)| corners.get(*k) == Some(&true)).map(|(ai, _)| (0, ai)).collect()
|
||||
}
|
||||
/// The radius (in document units, see [`LiveShape::folded`]) and kind the `corners` of a
|
||||
/// rectangle share (each `None` when they differ).
|
||||
pub fn corner_style(&self, corners: [bool; 4]) -> (Option<f64>, Option<CornerKind>) {
|
||||
let LiveShape::Rectangle { radii, kinds, .. } = self.folded() else { return (None, None) };
|
||||
fn shared<T: Copy>(v: [T; 4], corners: [bool; 4], same: impl Fn(T, T) -> bool) -> Option<T> {
|
||||
let mut it = v.into_iter().zip(corners).filter(|(_, on)| *on).map(|(x, _)| x);
|
||||
let first = it.next()?;
|
||||
it.all(|x| same(x, first)).then_some(first)
|
||||
}
|
||||
(shared(radii, corners, |a, b| (a - b).abs() < 1e-9), shared(kinds, corners, |a, b| a == b))
|
||||
radii.iter_mut().for_each(|r| *r /= k);
|
||||
true
|
||||
}
|
||||
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
|
||||
pub fn angle_deg(&self) -> f64 {
|
||||
@@ -247,14 +261,33 @@ impl LiveShape {
|
||||
c[1].atan2(c[0]).to_degrees()
|
||||
}
|
||||
LiveShape::Line { a, b } => (b.y - a.y).atan2(b.x - a.x).to_degrees(),
|
||||
LiveShape::Path { .. } => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn all_round(kinds: &[CornerKind; 4]) -> bool {
|
||||
fn all_round(kinds: &[CornerKind]) -> bool {
|
||||
kinds.iter().all(|k| *k == CornerKind::Round)
|
||||
}
|
||||
|
||||
/// Whether `a` only moves (no rotation, scale, shear or reflection).
|
||||
fn moves_only(a: Affine) -> bool {
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
(a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12
|
||||
}
|
||||
|
||||
/// Scale corner radii in document units by the mean scale of `a`, applied to their path. True
|
||||
/// when the path must be cut again: some corner is cut and `a` does more than move it (an
|
||||
/// uneven scale would otherwise make the cuts elliptical).
|
||||
fn scale_radii(radii: &mut [f64], a: Affine) -> bool {
|
||||
if moves_only(a) || !radii.iter().any(|r| *r > 0.0) {
|
||||
return false;
|
||||
}
|
||||
let k = a.determinant().abs().sqrt();
|
||||
radii.iter_mut().for_each(|r| *r *= k);
|
||||
true
|
||||
}
|
||||
|
||||
/// What a transform scales besides geometry, by its mean scale (the square root of its
|
||||
/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke
|
||||
/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects
|
||||
@@ -1214,7 +1247,7 @@ impl Node {
|
||||
|
||||
/// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre.
|
||||
pub fn shows_center_by_default(&self) -> bool {
|
||||
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. }))
|
||||
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. } | LiveShape::Path { .. }))
|
||||
}
|
||||
|
||||
/// Change this object's attributes with `f`; all-default attributes are dropped.
|
||||
@@ -1276,23 +1309,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_corners_map_to_their_anchors() {
|
||||
fn corner_kinds_save_only_when_some_corner_isnt_round() {
|
||||
let mut l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
|
||||
// Anchors: top-left, the top-right corner's two, bottom-right, bottom-left.
|
||||
assert_eq!(l.anchor_corners(), [0, 1, 1, 2, 3]);
|
||||
assert_eq!(l.to_path().anchor_count(), 5);
|
||||
let set = |v: &[usize]| v.iter().map(|ai| (0, *ai)).collect::<BTreeSet<AnchorRef>>();
|
||||
assert_eq!(l.corners_of(&set(&[2, 4])), [false, true, false, true]);
|
||||
assert_eq!(l.corners_of(&set(&[9])), [false; 4], "out of range");
|
||||
assert_eq!(l.corner_anchors([false, true, false, true]), set(&[1, 2, 4]));
|
||||
// Partly selected: the corners of the selected anchors; wholly (or no anchor): all four.
|
||||
assert_eq!(l.picked_corners(Some(&set(&[0]))), [true, false, false, false]);
|
||||
assert_eq!(l.picked_corners(None), [true; 4]);
|
||||
assert_eq!(l.picked_corners(Some(&set(&[]))), [true; 4]);
|
||||
assert_eq!(l.corner_style([false, true, false, false]), (Some(10.0), Some(CornerKind::Round)));
|
||||
assert_eq!(l.corner_style([true, true, false, false]), (None, Some(CornerKind::Round)));
|
||||
assert_eq!(l.corner_style([true, false, true, true]), (Some(0.0), Some(CornerKind::Round)));
|
||||
// Corner kinds save only when some corner isn't round, and older files read as round.
|
||||
// Older files read as round.
|
||||
let json = serde_json::to_value(&l).unwrap();
|
||||
assert!(json.get("kinds").is_none(), "{json}");
|
||||
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), l);
|
||||
@@ -1302,7 +1321,16 @@ mod tests {
|
||||
let json = serde_json::to_value(&l).unwrap();
|
||||
assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"]));
|
||||
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), l);
|
||||
assert!(LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }.anchor_corners().is_empty());
|
||||
// Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips.
|
||||
let p: LiveShape =
|
||||
serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap();
|
||||
assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![] });
|
||||
let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0);
|
||||
let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![] };
|
||||
let json = serde_json::to_value(&path).unwrap();
|
||||
assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None));
|
||||
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), path);
|
||||
assert_eq!(path.label(), "Path");
|
||||
}
|
||||
|
||||
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
|
||||
@@ -1427,7 +1455,8 @@ mod tests {
|
||||
assert!(near_xf(*xf, Affine::translate((5.0, 5.0))));
|
||||
let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap());
|
||||
assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}");
|
||||
assert_eq!(live.corner_style([true; 4]).0, Some(40.0), "the radius in document units");
|
||||
let corners = crate::LiveCorners::of(&n).unwrap();
|
||||
assert_eq!(corners.style(&corners.all()).0, Some(40.0), "the radius in document units");
|
||||
// Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are.
|
||||
let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0);
|
||||
let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]);
|
||||
|
||||
@@ -188,7 +188,8 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = f64_req(p, "radius", "shape.polygon")?.abs();
|
||||
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
|
||||
let rot = f64_or(p, "rotation", 0.0);
|
||||
let live = LiveShape::Polygon { radius: r, sides, xf: Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians()) };
|
||||
let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians());
|
||||
let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![] };
|
||||
add_art(s, "Polygon", path_kind(live.to_path(), Some(live)), None)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Object menu: transforms, arrange, group, lock/hide, compound paths, clipping masks, isolation,
|
||||
//! align & distribute, object properties.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, OrientedBox, Scaling};
|
||||
use vectorcraft_doc::corners::set_corners;
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
|
||||
|
||||
@@ -194,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Shape Properties",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [0..3…], sides?: n} (Live Corners: radius and kind set a rectangle's corners: `corners` (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left), else the corners with a Direct-Selected anchor, else all four; sides: a polygon's)",
|
||||
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
|
||||
has_selection,
|
||||
set_live_shape
|
||||
),
|
||||
@@ -1006,21 +1008,12 @@ fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The `corners` of `object.setLiveShape`: a mask of corner indices 0–3.
|
||||
fn corners_param(p: &Value) -> Result<Option<[bool; 4]>> {
|
||||
/// The `corners` of `object.setLiveShape`: anchor indices of a path's uncut outline.
|
||||
fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
|
||||
let Some(v) = p.get("corners").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let err = || {
|
||||
bad(
|
||||
"object.setLiveShape",
|
||||
format!("`corners` must list corner indices 0–3 (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left), not {v}"),
|
||||
)
|
||||
};
|
||||
let mut mask = [false; 4];
|
||||
for k in v.as_array().ok_or_else(err)? {
|
||||
let slot = k.as_u64().and_then(|k| mask.get_mut(usize::try_from(k).ok()?)).ok_or_else(err)?;
|
||||
*slot = true;
|
||||
}
|
||||
Ok(Some(mask))
|
||||
let err = || bad("object.setLiveShape", format!("`corners` must list anchor indices (0, 1, 2…), not {v}"));
|
||||
let list = v.as_array().ok_or_else(err)?;
|
||||
list.iter().map(|k| k.as_u64().and_then(|k| usize::try_from(k).ok()).ok_or_else(err)).collect::<Result<_>>().map(Some)
|
||||
}
|
||||
|
||||
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1035,39 +1028,37 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.transpose()?;
|
||||
let radius = p.get("radius").and_then(Value::as_f64);
|
||||
let sides = p.get("sides").and_then(Value::as_u64);
|
||||
s.edit("Live Shape", |d, sel| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Path { path, live: Some(live), .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
// Radii are document lengths and corners circular, also on a rectangle from a file
|
||||
// that kept an uneven scale in its transform (#442).
|
||||
live.fold_scale();
|
||||
let partial = sel.anchors.get_mut(id);
|
||||
let picked = corners.unwrap_or_else(|| live.picked_corners(partial.as_deref()));
|
||||
let (layout, selected) = (live.anchor_corners(), partial.as_deref().map(|a| live.corners_of(a)));
|
||||
match live {
|
||||
vectorcraft_doc::LiveShape::Rectangle { radii, kinds, .. } => {
|
||||
for ((_, r), k) in picked.iter().zip(radii.iter_mut()).zip(kinds.iter_mut()).filter(|((on, _), _)| **on) {
|
||||
if let Some(radius) = radius {
|
||||
*r = radius.max(0.0);
|
||||
}
|
||||
if let Some(kind) = kind {
|
||||
*k = kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
|
||||
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
|
||||
*sides = n.clamp(3, 1000) as u32;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
if let (Some(l @ LiveShape::Polygon { .. }), Some(n)) = (live.as_mut(), sides) {
|
||||
l.set_sides(n);
|
||||
*path = l.to_path();
|
||||
}
|
||||
*path = live.to_path();
|
||||
if radius.is_none() && kind.is_none() {
|
||||
continue;
|
||||
}
|
||||
let partial = sel.anchors.get_mut(id);
|
||||
// The corners to edit, the Direct-Selected ones and the anchor layout before the edit.
|
||||
let Some(c) = LiveCorners::new(path, live.as_ref()) else { continue };
|
||||
let picked = match &corners {
|
||||
Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => {
|
||||
let n = c.base.anchor_count();
|
||||
let past: Vec<_> = k.range(n..).collect();
|
||||
return Err(bad("object.setLiveShape", format!("object {id} has anchors 0–{} for `corners`, not {past:?}", n.saturating_sub(1))));
|
||||
}
|
||||
Some(k) => k.clone(),
|
||||
None => c.picked(partial.as_deref()),
|
||||
};
|
||||
let (selected, layout) = (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec());
|
||||
set_corners(path, live, &picked, radius, kind);
|
||||
// Direct-Selected corners stay selected as they gain or lose anchors.
|
||||
if let (Some(anchors), Some(selected)) = (partial, selected)
|
||||
&& live.anchor_corners() != layout
|
||||
&& let Some(c) = LiveCorners::new(path, live.as_ref())
|
||||
&& c.sources() != layout
|
||||
{
|
||||
*anchors = live.corner_anchors(selected);
|
||||
*anchors = c.anchors_of(&selected);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Live Corners on live rectangles: `object.setLiveShape` rounds the given corners, the
|
||||
//! Direct-Selected ones or all four, sets corner kinds, and keeps selected corners selected.
|
||||
//! Live Corners: `object.setLiveShape` rounds the given corners of a live rectangle, the
|
||||
//! Direct-Selected ones or all four, sets corner kinds, and keeps selected corners selected; and
|
||||
//! does the same on any path's corners: a star, a live polygon, a pen path (#511).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{AnchorRef, LiveShape, NodeKind};
|
||||
use vectorcraft_doc::{AnchorRef, LiveCorners, LiveShape, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
|
||||
@@ -150,3 +151,131 @@ fn corners_of_a_non_square_rectangle_are_alike_circles() {
|
||||
run(&mut s, json!({"radius": 8}));
|
||||
assert_spans(&s, id, [8.0; 4]);
|
||||
}
|
||||
|
||||
/// A selected five-pointed star at (200, 200) of radii 60 and 30: a plain path.
|
||||
fn star(s: &mut Session) -> NodeId {
|
||||
NodeId(s.execute("shape.star", &json!({"cx": 200, "cy": 200, "radius1": 60, "radius2": 30})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn node_path(s: &Session, id: NodeId) -> (vectorcraft_geom::PathData, Option<LiveShape>) {
|
||||
let NodeKind::Path { path, live, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not a path") };
|
||||
(path.clone(), live.clone())
|
||||
}
|
||||
|
||||
/// The radius and kind every corner of `id` shares, and how many corners it has.
|
||||
fn style(s: &Session, id: NodeId) -> (Option<f64>, Option<CornerKind>, usize) {
|
||||
let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap();
|
||||
let (r, k) = c.style(&c.all());
|
||||
(r, k, c.corners.len())
|
||||
}
|
||||
|
||||
/// The radius set on each of the first `N` corners of `id`.
|
||||
fn radii<const N: usize>(s: &Session, id: NodeId) -> [f64; N] {
|
||||
let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap();
|
||||
std::array::from_fn(|k| c.radius(k))
|
||||
}
|
||||
|
||||
/// #511: a star (a plain path) rounds all ten corners as one step, keeps them editable (a new
|
||||
/// radius cuts the outline again rather than the cut path), and is the plain star again at 0.
|
||||
#[test]
|
||||
fn a_star_rounds_its_corners_and_keeps_them_live() {
|
||||
let (mut s, _) = session();
|
||||
let id = star(&mut s);
|
||||
let (sharp, _) = node_path(&s, id);
|
||||
assert_eq!(style(&s, id), (Some(0.0), Some(CornerKind::Round), 10));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, json!({"radius": 4}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let (path, live) = node_path(&s, id);
|
||||
assert_eq!(path.anchor_count(), 20);
|
||||
assert!(matches!(&live, Some(LiveShape::Path { base, .. }) if *base == sharp), "{live:?}");
|
||||
assert_eq!(style(&s, id), (Some(4.0), Some(CornerKind::Round), 10));
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().kind_label(), "Path");
|
||||
run(&mut s, json!({"radius": 6, "kind": "chamfer"}));
|
||||
assert_eq!((node_path(&s, id).0.anchor_count(), style(&s, id)), (20, (Some(6.0), Some(CornerKind::Chamfer), 10)));
|
||||
run(&mut s, json!({"radius": 0, "kind": "round"}));
|
||||
assert_eq!(node_path(&s, id), (sharp, None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(style(&s, id).0, Some(6.0));
|
||||
}
|
||||
|
||||
/// Each corner stops at its own limit: its cut reaches halfway along its shorter side, so a
|
||||
/// star's neighbouring corners meet without overlapping.
|
||||
#[test]
|
||||
fn star_corners_stop_at_their_own_limit() {
|
||||
let (mut s, _) = session();
|
||||
let id = star(&mut s);
|
||||
run(&mut s, json!({"radius": 1000}));
|
||||
let (path, live) = node_path(&s, id);
|
||||
let Some(LiveShape::Path { base, .. }) = live else { panic!("live corners") };
|
||||
// Every cut ends at the middle of a side: each side's two cuts meet there.
|
||||
let sp = &base.subpaths[0];
|
||||
for (i, a) in sp.anchors.iter().enumerate() {
|
||||
let mid = a.p.midpoint(sp.anchors[(i + 1) % 10].p);
|
||||
assert!(path.anchors().any(|(_, _, b)| b.p.distance(mid) < 1e-9), "a cut ends at {mid:?}");
|
||||
}
|
||||
assert_eq!(style(&s, id).0, Some(1000.0), "the radius set is kept; it draws as large as fits");
|
||||
}
|
||||
|
||||
/// #511: Direct Selection picks one of the star's anchors: that corner alone rounds, and both of
|
||||
/// its new anchors stay selected.
|
||||
#[test]
|
||||
fn a_direct_selected_star_anchor_rounds_its_corner_alone() {
|
||||
let (mut s, _) = session();
|
||||
let id = star(&mut s);
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"})).unwrap();
|
||||
run(&mut s, json!({"radius": 5}));
|
||||
assert_eq!(node_path(&s, id).0.anchor_count(), 11);
|
||||
// The top tip's cut ends the path and starts it.
|
||||
assert_eq!(selected_anchors(&s, id), Some(anchors(&[0, 10])));
|
||||
assert_eq!(radii::<2>(&s, id), [5.0, 0.0]);
|
||||
// Explicit corners, as MCP and the widgets give them.
|
||||
run(&mut s, json!({"id": id.0, "corners": [1, 3], "radius": 2}));
|
||||
assert_eq!(radii::<4>(&s, id), [5.0, 2.0, 0.0, 2.0]);
|
||||
// Past the outline's last anchor is an error, and nothing changes.
|
||||
assert!(s.execute("object.setLiveShape", &json!({"id": id.0, "corners": [10], "radius": 1})).is_err());
|
||||
assert_eq!(node_path(&s, id).0.anchor_count(), 13);
|
||||
}
|
||||
|
||||
/// A live polygon stays live with cut corners, which keep their radius as its sides change and
|
||||
/// stay circular when it is scaled unevenly (the radius scales by the mean scale).
|
||||
#[test]
|
||||
fn a_polygon_keeps_its_corners_through_sides_and_scales() {
|
||||
let (mut s, _) = session();
|
||||
let id = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 80, "sides": 6})).unwrap()["id"].as_u64().unwrap());
|
||||
run(&mut s, json!({"radius": 10}));
|
||||
let (path, live) = node_path(&s, id);
|
||||
assert_eq!(path.anchor_count(), 12);
|
||||
assert!(matches!(live, Some(LiveShape::Polygon { sides: 6, .. })));
|
||||
run(&mut s, json!({"sides": 8}));
|
||||
assert_eq!((node_path(&s, id).0.anchor_count(), style(&s, id)), (16, (Some(10.0), Some(CornerKind::Round), 8)));
|
||||
s.execute("object.scale", &json!({"sx": 400, "sy": 100, "corners": true})).unwrap();
|
||||
assert!(matches!(node_path(&s, id).1, Some(LiveShape::Polygon { .. })));
|
||||
assert!((style(&s, id).0.unwrap() - 20.0).abs() < 1e-9);
|
||||
// Every cut is a circle of that radius: its ends are as far from its centre.
|
||||
let path = node_path(&s, id).0;
|
||||
let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap();
|
||||
for k in &c.corners {
|
||||
let centre = k.on_bisector(20.0 / k.sin());
|
||||
let ends = path.anchors().filter(|(_, _, a)| (a.p.distance(centre) - 20.0).abs() < 1e-6).count();
|
||||
assert_eq!(ends, 2, "corner {} is circular", k.index);
|
||||
}
|
||||
}
|
||||
|
||||
/// #511: a pen path's corners round; its ends don't. An ellipse has no corners to round.
|
||||
#[test]
|
||||
fn a_pen_path_rounds_its_corners_but_not_its_ends() {
|
||||
let (mut s, _) = session();
|
||||
let pen = json!({"anchors": [{"x": 10, "y": 200}, {"x": 110, "y": 100}, {"x": 210, "y": 200}, {"x": 310, "y": 100}]});
|
||||
let id = NodeId(s.execute("path.create", &pen).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
run(&mut s, json!({"radius": 8}));
|
||||
let path = node_path(&s, id).0;
|
||||
assert_eq!(path.anchor_count(), 6);
|
||||
let sp = &path.subpaths[0];
|
||||
assert_eq!((sp.anchors[0].p.x, sp.anchors[5].p.x), (10.0, 310.0), "the ends stay");
|
||||
let e = NodeId(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 30})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = node_path(&s, e);
|
||||
run(&mut s, json!({"id": e.0, "radius": 8}));
|
||||
assert_eq!(node_path(&s, e), before);
|
||||
}
|
||||
@@ -488,6 +488,10 @@ fn arb_rich_op() -> impl Strategy<Value = String> {
|
||||
"/Sh0 sh",
|
||||
"/Sh1 sh",
|
||||
"BT /F1 12 Tf 10 10 Td (Hi there) Tj [(a) -900 (b)] TJ ET",
|
||||
// Lines wrapped in a frame, digits set apart by gaps, glyph outlines stroked (#508).
|
||||
"BT /F1 10 Tf 10 80 Td (Wrapped line ) Tj 0 -12 Td (and its next ) Tj 0 -12 Td (end.) Tj ET",
|
||||
"BT /F1 10 Tf 10 40 Td (3) Tj 20 0 Td (5331) Tj 40 0 Td (0106) Tj ET",
|
||||
"0.5 w 10 10 m 14 18 l 18 10 l h S",
|
||||
"7 Tr",
|
||||
"q 10 10 50 50 re W n",
|
||||
"Q",
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Live Corners on any path: the anchors where two straight sides meet at an angle, and the path
|
||||
//! with those corners cut round, inverted round or chamfered. Rectangles cut theirs the same way.
|
||||
|
||||
use kurbo::{Point, Vec2};
|
||||
|
||||
use crate::path::{Anchor, AnchorKind, PathData, SubPath};
|
||||
use crate::shapes::{CornerKind, KAPPA};
|
||||
|
||||
/// Lengths and radii at or below this are none at all.
|
||||
const EPS: f64 = 1e-9;
|
||||
/// Sides closer than this to straight on (or folded back), as the sine of their angle, make no corner.
|
||||
const MIN_SINE: f64 = 1e-6;
|
||||
|
||||
/// A corner Live Corners can cut: an anchor without handles between two straight sides that
|
||||
/// meet at an angle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Corner {
|
||||
/// The anchor's index in its path, counting the anchors of every subpath in order.
|
||||
pub index: usize,
|
||||
/// Where the sides meet.
|
||||
pub at: Point,
|
||||
/// Unit directions along the sides: towards the previous anchor, then towards the next one.
|
||||
pub u: Vec2,
|
||||
pub v: Vec2,
|
||||
/// The sides' lengths, in the same order.
|
||||
pub lu: f64,
|
||||
pub lv: f64,
|
||||
}
|
||||
|
||||
impl Corner {
|
||||
/// The corner of anchor `i` of `sp`, whose index in the path is `index`; `None` when the
|
||||
/// anchor is an end of an open subpath, has handles or is smooth, when a side curves or has
|
||||
/// no length, or when the sides run straight on.
|
||||
fn of(sp: &SubPath, i: usize, index: usize) -> Option<Self> {
|
||||
let n = sp.anchors.len();
|
||||
let (prev, next) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) };
|
||||
let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev)?, sp.anchors.get(next)?);
|
||||
if a.kind == AnchorKind::Smooth || a.has_in() || a.has_out() || prev.has_out() || next.has_in() {
|
||||
return None;
|
||||
}
|
||||
let (du, dv) = (prev.p - a.p, next.p - a.p);
|
||||
let (lu, lv) = (du.hypot(), dv.hypot());
|
||||
if !(lu > EPS && lv > EPS && lu.is_finite() && lv.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let c = Self { index, at: a.p, u: du / lu, v: dv / lv, lu, lv };
|
||||
(c.sin() > MIN_SINE).then_some(c)
|
||||
}
|
||||
|
||||
/// The cosine of the angle between the sides.
|
||||
pub fn cos(&self) -> f64 {
|
||||
self.u.dot(self.v)
|
||||
}
|
||||
|
||||
/// The sine of the angle between the sides.
|
||||
pub fn sin(&self) -> f64 {
|
||||
self.u.cross(self.v).abs()
|
||||
}
|
||||
|
||||
/// The angle between the sides, in degrees (90 at a rectangle's corners).
|
||||
pub fn angle_deg(&self) -> f64 {
|
||||
self.sin().atan2(self.cos()).to_degrees()
|
||||
}
|
||||
|
||||
/// How far from the corner a round cut of radius `r` meets each side: r / tan(angle / 2).
|
||||
pub fn setback(&self, r: f64) -> f64 {
|
||||
r * (1.0 + self.cos()) / self.sin()
|
||||
}
|
||||
|
||||
/// The largest radius the corner draws: its cut then reaches halfway along the shorter side,
|
||||
/// so neighbouring corners at their largest meet without overlapping (half the shorter side
|
||||
/// at a rectangle's corners).
|
||||
pub fn max_radius(&self) -> f64 {
|
||||
self.lu.min(self.lv) / 2.0 * self.sin() / (1.0 + self.cos())
|
||||
}
|
||||
|
||||
/// The radius the corner is drawn with: `radius`, no larger than [`Corner::max_radius`], 0 when
|
||||
/// negative or not a number.
|
||||
pub fn fitted(&self, radius: f64) -> f64 {
|
||||
radius.max(0.0).min(self.max_radius())
|
||||
}
|
||||
|
||||
/// The point `k` along the bisector, measured in (u + v): the centre of a round cut of radius
|
||||
/// `r` is at `k = r / sin`.
|
||||
pub fn on_bisector(&self, k: f64) -> Point {
|
||||
self.at + (self.u + self.v) * k
|
||||
}
|
||||
|
||||
/// How much the radius grows when the centre of the round cut moves by `d`: `d`'s part along
|
||||
/// the bisector, in radius units.
|
||||
pub fn radius_change(&self, d: Vec2) -> f64 {
|
||||
d.dot(self.u + self.v) * self.sin() / (2.0 + 2.0 * self.cos())
|
||||
}
|
||||
|
||||
/// The two anchors replacing the corner when cut with `radius` (> 0, fitted): where the cut
|
||||
/// meets the arriving side, then the leaving one.
|
||||
fn cut(&self, radius: f64, kind: CornerKind) -> [Anchor; 2] {
|
||||
let t = self.setback(radius);
|
||||
let (start, end) = (self.at + self.u * t, self.at + self.v * t);
|
||||
let (h_out, h_in) = match kind {
|
||||
// An arc of `radius` tangent to both sides, turning by 180° less the angle.
|
||||
CornerKind::Round => {
|
||||
let h = radius * arc_handle(-self.cos());
|
||||
(start - self.u * h, end - self.v * h)
|
||||
}
|
||||
// An arc centred on the corner through both ends, spanning the angle.
|
||||
CornerKind::InvertedRound => {
|
||||
let h = t * arc_handle(self.cos());
|
||||
(start + square_to(self.u, self.v) * h, end + square_to(self.v, self.u) * h)
|
||||
}
|
||||
CornerKind::Chamfer => (start, end),
|
||||
};
|
||||
[Anchor { p: start, h_in: start, h_out, kind: AnchorKind::Corner }, Anchor { p: end, h_in, h_out: end, kind: AnchorKind::Corner }]
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit direction square to unit `a`, on `b`'s side.
|
||||
fn square_to(a: Vec2, b: Vec2) -> Vec2 {
|
||||
let w = b - a * a.dot(b);
|
||||
w / w.hypot()
|
||||
}
|
||||
|
||||
/// The handle length, per unit of radius, of a cubic drawing a circular arc that spans an angle
|
||||
/// whose cosine is `cos`: 4/3 · tan(angle / 4) ([`KAPPA`] for a quarter circle).
|
||||
fn arc_handle(cos: f64) -> f64 {
|
||||
if cos.abs() < 1e-12 {
|
||||
return KAPPA;
|
||||
}
|
||||
let (sin_half, cos_half) = (((1.0 - cos) / 2.0).max(0.0).sqrt(), ((1.0 + cos) / 2.0).max(0.0).sqrt());
|
||||
4.0 / 3.0 * sin_half / (1.0 + cos_half)
|
||||
}
|
||||
|
||||
/// The corners of `path` Live Corners can cut, in anchor order.
|
||||
pub fn path_corners(path: &PathData) -> Vec<Corner> {
|
||||
let mut out = vec![];
|
||||
let mut first = 0;
|
||||
for sp in &path.subpaths {
|
||||
out.extend((0..sp.anchors.len()).filter_map(|i| Corner::of(sp, i, first + i)));
|
||||
first += sp.anchors.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `path` with each of its corners ([`path_corners`]) cut by the radius in `radii` and the kind in
|
||||
/// `kinds` at its anchor index (missing: 0 and round), each radius no larger than fits
|
||||
/// ([`Corner::fitted`]). A cut corner becomes two anchors; a closed subpath whose first corner
|
||||
/// is cut starts where that cut ends, so the cut closes it. Also returns, per subpath, the
|
||||
/// anchor index in `path` each anchor comes from.
|
||||
pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (PathData, Vec<Vec<usize>>) {
|
||||
let corners = path_corners(path);
|
||||
let mut corners = corners.iter().peekable();
|
||||
let (mut subpaths, mut sources) = (Vec::with_capacity(path.subpaths.len()), Vec::with_capacity(path.subpaths.len()));
|
||||
let mut first = 0;
|
||||
for sp in &path.subpaths {
|
||||
let (mut anchors, mut from) = (Vec::with_capacity(sp.anchors.len()), Vec::with_capacity(sp.anchors.len()));
|
||||
let mut closing = None;
|
||||
for (i, a) in sp.anchors.iter().enumerate() {
|
||||
let index = first + i;
|
||||
let corner = corners.next_if(|c| c.index == index);
|
||||
let radius = corner.map_or(0.0, |c| c.fitted(radii.get(index).copied().unwrap_or(0.0)));
|
||||
match corner {
|
||||
Some(c) if radius > EPS => {
|
||||
let [start, end] = c.cut(radius, kinds.get(index).copied().unwrap_or_default());
|
||||
if i == 0 && sp.closed {
|
||||
closing = Some(start);
|
||||
} else {
|
||||
anchors.push(start);
|
||||
from.push(index);
|
||||
}
|
||||
anchors.push(end);
|
||||
from.push(index);
|
||||
}
|
||||
_ => {
|
||||
anchors.push(*a);
|
||||
from.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(a) = closing {
|
||||
anchors.push(a);
|
||||
from.push(first);
|
||||
}
|
||||
first += sp.anchors.len();
|
||||
subpaths.push(SubPath::new(anchors, sp.closed));
|
||||
sources.push(from);
|
||||
}
|
||||
(PathData::new(subpaths), sources)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shapes;
|
||||
use kurbo::Rect;
|
||||
|
||||
fn near(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-9
|
||||
}
|
||||
|
||||
/// A five-pointed star's tips are acute corners, its inner corners obtuse: all ten cut, each
|
||||
/// a circle of the radius tangent to both sides.
|
||||
#[test]
|
||||
fn a_star_has_ten_corners_cut_as_circles() {
|
||||
let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0);
|
||||
let corners = path_corners(&star);
|
||||
assert_eq!(corners.len(), 10);
|
||||
let angles: Vec<_> = corners.iter().map(|c| c.angle_deg()).collect();
|
||||
assert!(angles.iter().step_by(2).all(|a| *a < 90.0), "tips are acute: {angles:?}");
|
||||
assert!(angles.iter().skip(1).step_by(2).all(|a| *a > 90.0), "inner corners are obtuse: {angles:?}");
|
||||
let (cut, from) = cut_corners(&star, &[4.0; 10], &[]);
|
||||
assert_eq!(cut.anchor_count(), 20);
|
||||
assert_eq!(from, [vec![0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 0]]);
|
||||
for c in &corners {
|
||||
// Both ends of the cut lie `setback` along the sides, as far from the arc's centre as
|
||||
// the radius, and that centre is `radius` from either side.
|
||||
let t = c.setback(4.0);
|
||||
let o = c.on_bisector(4.0 / c.sin());
|
||||
for p in [c.at + c.u * t, c.at + c.v * t] {
|
||||
assert!((p.distance(o) - 4.0).abs() < 1e-9);
|
||||
assert!(cut.anchors().any(|(_, _, a)| near(a.p, p)), "{p:?} is an anchor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radii_stop_at_half_the_shorter_side_per_corner() {
|
||||
let tri = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(0.0, 40.0)], true));
|
||||
let corners = path_corners(&tri);
|
||||
assert_eq!(corners.len(), 3);
|
||||
for c in &corners {
|
||||
let max = c.max_radius();
|
||||
assert!((c.setback(max) - c.lu.min(c.lv) / 2.0).abs() < 1e-9);
|
||||
assert_eq!(c.fitted(1e6), max);
|
||||
assert_eq!(c.fitted(-3.0), 0.0);
|
||||
assert_eq!(c.fitted(f64::NAN), 0.0);
|
||||
}
|
||||
// The right angle at (0, 0) takes up to 20, half its shorter side.
|
||||
assert!((corners[0].max_radius() - 20.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ends_curves_smooth_and_straight_anchors_are_no_corners() {
|
||||
// An open zig-zag: its ends aren't corners, nor is the anchor its sides run straight on through.
|
||||
let open = PathData::single(SubPath::polyline(
|
||||
&[Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0), Point::new(150.0, 80.0)],
|
||||
false,
|
||||
));
|
||||
assert_eq!(path_corners(&open).iter().map(|c| c.index).collect::<Vec<_>>(), [2, 3]);
|
||||
// A curve on either side, or handles on the anchor, leave it alone.
|
||||
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
|
||||
sp.anchors[1].h_out = Point::new(110.0, 10.0);
|
||||
let p = PathData::single(sp);
|
||||
assert_eq!(path_corners(&p).iter().map(|c| c.index).collect::<Vec<_>>(), [0, 3]);
|
||||
assert!(path_corners(&shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0))).is_empty());
|
||||
// A second subpath counts on from the first one's anchors.
|
||||
let two = PathData::new(vec![
|
||||
SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
SubPath::polyline(&[Point::new(50.0, 0.0), Point::new(60.0, 0.0), Point::new(50.0, 10.0)], true),
|
||||
]);
|
||||
let (cut, from) = cut_corners(&two, &[0.0, 0.0, 0.0, 1.0], &[]);
|
||||
assert_eq!(from, [vec![0, 1, 2], vec![3, 4, 5, 3]]);
|
||||
assert_eq!(cut.anchor_count(), 7);
|
||||
}
|
||||
|
||||
/// Inverted round and chamfer cuts end where the round one does, at any angle.
|
||||
#[test]
|
||||
fn kinds_share_their_ends() {
|
||||
let star = shapes::star(Point::new(0.0, 0.0), 50.0, 25.0, 5, 0.0);
|
||||
let ends = |k: CornerKind| -> Vec<Point> { cut_corners(&star, &[3.0; 10], &[k; 10]).0.anchors().map(|(_, _, a)| a.p).collect() };
|
||||
let round = ends(CornerKind::Round);
|
||||
assert_eq!(ends(CornerKind::InvertedRound), round);
|
||||
assert_eq!(ends(CornerKind::Chamfer), round);
|
||||
// The inverted arc stays on the circle about the corner.
|
||||
let c = path_corners(&star)[0];
|
||||
let [s, e] = c.cut(3.0, CornerKind::InvertedRound);
|
||||
let bez = kurbo::CubicBez::new(s.p, s.h_out, e.h_in, e.p);
|
||||
let r = c.setback(3.0);
|
||||
for t in [0.25, 0.5, 0.75] {
|
||||
use kurbo::ParamCurve;
|
||||
assert!((bez.eval(t).distance(c.at) - r).abs() < r * 1e-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod arc;
|
||||
pub mod bez;
|
||||
pub mod corners;
|
||||
pub mod hit;
|
||||
pub mod path;
|
||||
pub mod projective;
|
||||
|
||||
+11
-48
@@ -62,52 +62,10 @@ pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 {
|
||||
/// Rectangle whose corners (top-left, top-right, bottom-right, bottom-left, y down) each have
|
||||
/// their own radius and kind, every radius clamped to half the shorter side. A corner without a
|
||||
/// radius is one anchor, the others two (where the cut meets each side). Clockwise from the
|
||||
/// top-left corner, its anchor on the top side first.
|
||||
/// top-left corner, its anchor on the top side first. Cut as any path's corners are
|
||||
/// ([`crate::corners::cut_corners`]).
|
||||
pub fn rectangle_with_corners(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> PathData {
|
||||
PathData::single(SubPath::new(corner_anchors(r, radii, kinds).into_iter().map(|(_, a)| a).collect(), true))
|
||||
}
|
||||
|
||||
/// The corner (index into `radii`) of each anchor of [`rectangle_with_corners`].
|
||||
pub fn rectangle_anchor_corners(r: Rect, radii: [f64; 4]) -> Vec<usize> {
|
||||
corner_anchors(r, radii, [CornerKind::Round; 4]).into_iter().map(|(k, _)| k).collect()
|
||||
}
|
||||
|
||||
/// The anchors of [`rectangle_with_corners`], each with its corner.
|
||||
fn corner_anchors(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> Vec<(usize, Anchor)> {
|
||||
let r = r.abs();
|
||||
// Each corner, with the directions of the side arriving at it and of the side leaving it.
|
||||
let corners = [
|
||||
(Point::new(r.x0, r.y0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0)),
|
||||
(Point::new(r.x1, r.y0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)),
|
||||
(Point::new(r.x1, r.y1), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0)),
|
||||
(Point::new(r.x0, r.y1), Vec2::new(-1.0, 0.0), Vec2::new(0.0, -1.0)),
|
||||
];
|
||||
let mut out = Vec::with_capacity(8);
|
||||
let mut top_left_start = None;
|
||||
for (k, ((c, din, dout), (rad, kind))) in corners.into_iter().zip(radii.into_iter().zip(kinds)).enumerate() {
|
||||
let rad = fitted_corner_radius(r.width(), r.height(), rad);
|
||||
if rad <= 1e-9 {
|
||||
out.push((k, Anchor::corner(c)));
|
||||
continue;
|
||||
}
|
||||
// The cut runs from `start` on the arriving side to `end` on the leaving one.
|
||||
let (start, end, h) = (c - din * rad, c + dout * rad, rad * KAPPA);
|
||||
let (h_out, h_in) = match kind {
|
||||
CornerKind::Round => (start + din * h, end - dout * h),
|
||||
CornerKind::InvertedRound => (start + dout * h, end - din * h),
|
||||
CornerKind::Chamfer => (start, end),
|
||||
};
|
||||
let start = Anchor { p: start, h_in: start, h_out, kind: AnchorKind::Corner };
|
||||
let end = Anchor { p: end, h_in, h_out: end, kind: AnchorKind::Corner };
|
||||
if k == 0 {
|
||||
top_left_start = Some(start);
|
||||
} else {
|
||||
out.push((k, start));
|
||||
}
|
||||
out.push((k, end));
|
||||
}
|
||||
out.extend(top_left_start.map(|a| (0, a)));
|
||||
out
|
||||
crate::corners::cut_corners(&rectangle(r), &radii, &kinds).0
|
||||
}
|
||||
|
||||
/// Ellipse inscribed in `r` with 4 smooth anchors (left, top, right, bottom).
|
||||
@@ -244,6 +202,11 @@ pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The corner (index into `radii`) of each anchor of [`rectangle_with_corners`].
|
||||
fn corner_sources(r: Rect, radii: [f64; 4]) -> Vec<usize> {
|
||||
crate::corners::cut_corners(&rectangle(r), &radii, &[]).1.concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_is_4_corners_clockwise() {
|
||||
let p = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
@@ -293,7 +256,7 @@ mod tests {
|
||||
a((x0, y0 + rad), (x0, y0 + rad), (x0, y0 + rad - k)),
|
||||
];
|
||||
assert_eq!(rounded_rectangle(r, rad).subpaths[0].anchors, want);
|
||||
assert_eq!(rectangle_anchor_corners(r, [rad; 4]), [0, 1, 1, 2, 2, 3, 3, 0]);
|
||||
assert_eq!(corner_sources(r, [rad; 4]), [0, 1, 1, 2, 2, 3, 3, 0]);
|
||||
assert_eq!(rounded_rectangle(r, 0.0), rectangle(r));
|
||||
}
|
||||
|
||||
@@ -305,11 +268,11 @@ mod tests {
|
||||
let p = rectangle_with_corners(r, radii, [CornerKind::Round; 4]);
|
||||
let pts: Vec<Point> = p.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(pts, [Point::new(0.0, 0.0), Point::new(90.0, 0.0), Point::new(100.0, 10.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0)]);
|
||||
assert_eq!(rectangle_anchor_corners(r, radii), [0, 1, 1, 2, 3]);
|
||||
assert_eq!(corner_sources(r, radii), [0, 1, 1, 2, 3]);
|
||||
let quarter = PI * 25.0;
|
||||
assert!((p.subpaths[0].area() - (6000.0 - 100.0 + quarter)).abs() < 0.1, "a quarter circle off one corner");
|
||||
// The top-left corner rounded: its top-side anchor first, its left-side one last.
|
||||
assert_eq!(rectangle_anchor_corners(r, [10.0, 0.0, 0.0, 200.0]), [0, 1, 2, 3, 3, 0]);
|
||||
assert_eq!(corner_sources(r, [10.0, 0.0, 0.0, 200.0]), [0, 1, 2, 3, 3, 0]);
|
||||
// Inverted round cuts a quarter circle out; a chamfer cuts a triangle.
|
||||
let inverted = rectangle_with_corners(r, radii, [CornerKind::InvertedRound; 4]);
|
||||
assert!((inverted.subpaths[0].area() - (6000.0 - quarter)).abs() < 0.1);
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::import_color::{Colors, Native};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
|
||||
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
|
||||
use crate::import_text::{Families, Look, Placement, TextLine, Upright};
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
/// The name of the paths text imports as.
|
||||
@@ -76,6 +76,33 @@ fn place_group(g: usize, ocgs: &Ocgs, slots: &mut Vec<Slot>, nesting: &mut Nesti
|
||||
slots.push(Slot::Group(child));
|
||||
}
|
||||
|
||||
/// The top-level group that group `g` is listed under (`g` itself when it isn't a sublayer).
|
||||
fn top_group(g: usize, ocgs: &Ocgs) -> usize {
|
||||
let mut top = g;
|
||||
for _ in 0..=MAX_NESTING {
|
||||
match ocgs.list.get(top).and_then(|o| o.parent) {
|
||||
Some(parent) => top = parent,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
top
|
||||
}
|
||||
|
||||
/// Merge the top-level groups a page marks (`page`, in the order it first marks each, its empty
|
||||
/// ones too) into the stacking order of the pages before (`order`, bottom first; `known`: its
|
||||
/// groups). A group new to it goes right below the first group the page marks after it that it
|
||||
/// has: a layer whose art starts on a later page keeps its place among the others (#508).
|
||||
fn merge_order(order: &mut Vec<usize>, known: &mut HashSet<usize>, page: &[usize]) {
|
||||
let mut above = None;
|
||||
for &g in page.iter().rev() {
|
||||
if known.insert(g) {
|
||||
let at = above.and_then(|a| order.iter().position(|&x| x == a)).unwrap_or(order.len());
|
||||
order.insert(at, g);
|
||||
}
|
||||
above = Some(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer of group `g`, in `color`: its art and sublayers. `None` for a group that isn't read.
|
||||
fn group_layer(
|
||||
b: &mut Builder<'_>,
|
||||
@@ -166,6 +193,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let mut slots: Vec<Slot> = vec![];
|
||||
let mut group_art: HashMap<usize, Vec<Arc<Node>>> = HashMap::new();
|
||||
let mut nesting = Nesting::new();
|
||||
// The top-level groups in the order the pages mark them, bottom first.
|
||||
let (mut order, mut known) = (vec![], HashSet::new());
|
||||
for (i, &number) in picked.iter().enumerate() {
|
||||
let Some(page) = pages.get(number) else { continue };
|
||||
// The chosen box sits at (x, 0); the page draws round it.
|
||||
@@ -175,6 +204,11 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone());
|
||||
b.page = ab;
|
||||
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts);
|
||||
if route {
|
||||
let mut seen = HashSet::new();
|
||||
let marked: Vec<usize> = scan.tags.iter().filter_map(|(_, g)| g.map(|g| top_group(g, &ocgs))).filter(|g| seen.insert(*g)).collect();
|
||||
merge_order(&mut order, &mut known, &marked);
|
||||
}
|
||||
b.begin_page(scan);
|
||||
interpret_page(page, &mut ctx, &mut b);
|
||||
let mut parts = b.end_page();
|
||||
@@ -239,8 +273,25 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
if placeholder {
|
||||
return Err(PdfError::PlaceholderOnly);
|
||||
}
|
||||
// A group the pages mark but draw nothing in is an empty layer, as the editor had it.
|
||||
for &g in &order {
|
||||
if !nesting.contains_key(&g) {
|
||||
slots.push(Slot::Group(g));
|
||||
}
|
||||
}
|
||||
// The groups' layers stack as the pages mark them (the art outside them stays where it was
|
||||
// drawn): a group that first paints on a later page doesn't go on top of the others.
|
||||
let rank: HashMap<usize, usize> = order.iter().enumerate().map(|(i, &g)| (g, i)).collect();
|
||||
let at: Vec<usize> = slots.iter().enumerate().filter(|(_, s)| matches!(s, Slot::Group(_))).map(|(i, _)| i).collect();
|
||||
let mut stacked: Vec<usize> = slots.iter().filter_map(|s| if let Slot::Group(g) = s { Some(*g) } else { None }).collect();
|
||||
stacked.sort_by_key(|g| rank.get(g).copied().unwrap_or(usize::MAX));
|
||||
for (i, g) in at.into_iter().zip(stacked) {
|
||||
if let Some(s) = slots.get_mut(i) {
|
||||
*s = Slot::Group(g);
|
||||
}
|
||||
}
|
||||
for (n, slot) in slots.into_iter().enumerate() {
|
||||
let layer = match slot {
|
||||
let mut layer = match slot {
|
||||
Slot::Page(l) => *l,
|
||||
// Sublayers take their top-level layer's colour.
|
||||
Slot::Group(g) => match group_layer(&mut b, g, &ocgs, &nesting, &mut group_art, LayerColor::Preset((n % 27) as u8)) {
|
||||
@@ -248,6 +299,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
if let Some(art) = layer.children_mut() {
|
||||
crate::import_lines::rebuild(art, &b.lines);
|
||||
}
|
||||
doc.layers.push(Arc::new(layer));
|
||||
}
|
||||
for (k, blob) in b.images.drain() {
|
||||
@@ -567,6 +621,8 @@ struct Builder<'p> {
|
||||
nested: u32,
|
||||
/// The last filled path: its node and its outline (a stroke of the same outline joins it).
|
||||
last_fill: Option<(NodeId, BezPath)>,
|
||||
/// What each line of type made tells beyond its text object ([`crate::import_lines`]).
|
||||
lines: HashMap<NodeId, LineFacts>,
|
||||
}
|
||||
|
||||
fn blend(b: hayro_interpret::BlendMode) -> BlendMode {
|
||||
@@ -701,6 +757,7 @@ impl<'p> Builder<'p> {
|
||||
pending: false,
|
||||
nested: 0,
|
||||
last_fill: None,
|
||||
lines: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,10 +843,11 @@ impl<'p> Builder<'p> {
|
||||
let current = self.current_group();
|
||||
let Some(f) = self.stack.last_mut() else { return };
|
||||
f.children.push(n);
|
||||
// A frame's art goes where its first art was drawn (a clip can end in a later group).
|
||||
if root {
|
||||
self.root_groups.push(group.or(current));
|
||||
} else if f.group.is_none() {
|
||||
f.group = group;
|
||||
f.group = group.or(current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,9 +1013,10 @@ impl<'p> Builder<'p> {
|
||||
}
|
||||
|
||||
fn flush_text(&mut self) {
|
||||
let Some((t, opacity)) = self.text.take().and_then(TextLine::finish) else { return };
|
||||
let Some((t, opacity, facts)) = self.text.take().and_then(TextLine::finish) else { return };
|
||||
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
|
||||
n.opacity = opacity;
|
||||
self.lines.insert(n.id, facts);
|
||||
self.push_node(n);
|
||||
}
|
||||
|
||||
@@ -1227,6 +1286,14 @@ impl<'p> Builder<'p> {
|
||||
place(&mut line);
|
||||
self.text = Some(line);
|
||||
}
|
||||
// Its ink: a stroke the file draws as outlines of the glyphs is told by it.
|
||||
let mut ink = o.outline();
|
||||
if let Some(line) = &mut self.text
|
||||
&& !ink.elements().is_empty()
|
||||
{
|
||||
ink.apply_affine(m);
|
||||
line.ink.push(ink.bounding_box());
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -1570,8 +1637,10 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
if self.nested > 0 {
|
||||
return;
|
||||
}
|
||||
if self.marked.pop().flatten().is_some() {
|
||||
// The runs gathered within a layer's sequence are its art: finish them before it ends.
|
||||
if self.marked.last().copied().flatten().is_some() {
|
||||
self.flush();
|
||||
}
|
||||
self.marked.pop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! What a file's lines of type were, beyond what each line says on its own (#508). An editor
|
||||
//! writes what PDF text can't say as more art next to the text, and the type it wrapped in a
|
||||
//! frame as lines:
|
||||
//! - a stroke added to a type object (above or below its characters) comes as the outline of each
|
||||
//! glyph stroked, one path per glyph, right after the type (or right before it): it is the
|
||||
//! type's stroke again, so the type stays one object;
|
||||
//! - area type comes as a line of point type per line, each wrapped line ending in a space. Lines
|
||||
//! in a row sharing a left edge, font, size and leading (and paragraph spacing) are one area
|
||||
//! type object again, aligned left or justified, where laying its text out in the frame breaks
|
||||
//! it where the file does: else, as where its font isn't installed, they stay lines.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Point, Rect, Shape};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{AppearanceItem, Justify, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
use crate::import_text::LineFacts;
|
||||
|
||||
/// Rebuild the type among `nodes` (and in their groups) from what its lines tell (`lines`).
|
||||
pub(crate) fn rebuild(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
for n in nodes.iter_mut() {
|
||||
if n.children().is_some_and(|c| !c.is_empty())
|
||||
&& let Some(children) = Arc::make_mut(n).children_mut()
|
||||
{
|
||||
rebuild(children, lines);
|
||||
}
|
||||
}
|
||||
join_strokes(nodes, lines);
|
||||
join_paragraphs(nodes, lines);
|
||||
}
|
||||
|
||||
/// The stroke paths `nodes` draw when they are the outlines of the glyphs whose ink boxes are
|
||||
/// `ink`, stroked one by one alike (no fill, mask or blending, fully opaque).
|
||||
fn outline_stroke(nodes: &[Arc<Node>], ink: &[Rect]) -> Option<AppearanceItem> {
|
||||
let first = nodes.first()?;
|
||||
let [stroke @ AppearanceItem::Stroke(_)] = first.appearance.items.as_slice() else { return None };
|
||||
let on_glyph = |n: &Node, r: &Rect| {
|
||||
let tol = 0.01 * (r.width() + r.height()) + 0.01;
|
||||
let NodeKind::Path { path, clipping: false, guide: false, live: None, .. } = &n.kind else { return false };
|
||||
let b = path.to_bezpath().bounding_box();
|
||||
n.appearance == first.appearance
|
||||
&& n.mask.is_none()
|
||||
&& n.opacity >= 0.999
|
||||
&& n.blend == BlendMode::Normal
|
||||
&& [(b.x0, r.x0), (b.y0, r.y0), (b.x1, r.x1), (b.y1, r.y1)].iter().all(|(a, b)| (a - b).abs() <= tol)
|
||||
};
|
||||
(nodes.len() == ink.len() && nodes.iter().zip(ink).all(|(n, r)| on_glyph(n, r))).then(|| stroke.clone())
|
||||
}
|
||||
|
||||
/// Each type object among `nodes` whose glyphs' outlines are stroked by the paths right after it
|
||||
/// (or right before it) takes that stroke above (or below) its characters, and the paths go.
|
||||
fn join_strokes(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
|
||||
let mut i = 0;
|
||||
while i < nodes.len() {
|
||||
let ink = nodes
|
||||
.get(i)
|
||||
.filter(|n| matches!(n.kind, NodeKind::Text(_)) && n.appearance.items.is_empty())
|
||||
.and_then(|n| lines.get(&n.id))
|
||||
.map_or(&[][..], |f| f.ink.as_slice());
|
||||
let k = ink.len();
|
||||
if k > 0 {
|
||||
if let Some(stroke) = nodes.get(i + 1..i + 1 + k).and_then(|after| outline_stroke(after, ink)) {
|
||||
nodes.drain(i + 1..i + 1 + k);
|
||||
if let Some(t) = nodes.get_mut(i) {
|
||||
Arc::make_mut(t).appearance.items.push(stroke);
|
||||
}
|
||||
} else if let Some(stroke) = i.checked_sub(k).and_then(|from| nodes.get(from..i)).and_then(|before| outline_stroke(before, ink)) {
|
||||
nodes.drain(i - k..i);
|
||||
i -= k;
|
||||
if let Some(t) = nodes.get_mut(i) {
|
||||
let ap = &mut Arc::make_mut(t).appearance;
|
||||
ap.items.insert(0, stroke);
|
||||
ap.set_contents_at(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` as a line that can be part of area type: horizontal point type, with what its line told.
|
||||
fn line<'a>(n: &'a Node, lines: &'a HashMap<NodeId, LineFacts>) -> Option<(&'a TextObject, &'a LineFacts)> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let point = matches!(t.kind, TextKind::Point) && !t.vertical && n.mask.is_none() && !t.runs.is_empty();
|
||||
point.then_some(())?;
|
||||
Some((t, lines.get(&n.id)?))
|
||||
}
|
||||
|
||||
/// Lines of type in a row that may be one area type object.
|
||||
struct Block {
|
||||
/// The step from a wrapped line's baseline to the next.
|
||||
leading: f64,
|
||||
/// The extra step after a line ending a paragraph.
|
||||
space: f64,
|
||||
/// Each line's baseline below the first (text space).
|
||||
ys: Vec<f64>,
|
||||
}
|
||||
|
||||
/// The lines from `nodes`' first on that share its left edge, angle, font and size, its look
|
||||
/// (opacity, blending, appearance) and steps down by one leading after a wrapped line and by
|
||||
/// another (the leading and paragraph spacing) after a line ending a paragraph: how many, and
|
||||
/// the block they make unless none of them was wrapped (0 and `None`: the first isn't a line).
|
||||
fn block(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>) -> (usize, Option<Block>) {
|
||||
let Some((head, (t0, f0))) = nodes.first().and_then(|n| Some((n, line(n, lines)?))) else { return (0, None) };
|
||||
let st = t0.first_style();
|
||||
let size = st.size;
|
||||
let inv = t0.xf.inverse();
|
||||
let lin = |t: &TextObject| {
|
||||
let [a, b, c, d, ..] = t.xf.as_coeffs();
|
||||
[a, b, c, d]
|
||||
};
|
||||
let (mut ys, mut wraps) = (vec![0.0], f0.wraps);
|
||||
let (mut leading, mut para): (Option<f64>, Option<f64>) = (None, None);
|
||||
for n in nodes.iter().skip(1) {
|
||||
let Some((t, f)) = line(n, lines) else { break };
|
||||
let s = t.first_style();
|
||||
let alike = n.opacity == head.opacity
|
||||
&& n.blend == head.blend
|
||||
&& n.appearance == head.appearance
|
||||
&& s.font_family == st.font_family
|
||||
&& s.font_style == st.font_style
|
||||
&& (s.size - size).abs() <= size * 0.01
|
||||
&& t.para == t0.para
|
||||
&& lin(t).iter().zip(lin(t0)).all(|(a, b)| (a - b).abs() < 1e-6);
|
||||
let at = inv * (t.xf * Point::ZERO);
|
||||
let step = at.y - ys.last().copied().unwrap_or_default();
|
||||
if !alike || at.x.abs() > size * 0.02 || !(step > size * 0.5 && step < size * 4.0) {
|
||||
break;
|
||||
}
|
||||
let slot = if wraps { &mut leading } else { &mut para };
|
||||
match *slot {
|
||||
Some(s) if (s - step).abs() > size * 0.01 => break,
|
||||
Some(_) => {}
|
||||
None => *slot = Some(step),
|
||||
}
|
||||
ys.push(at.y);
|
||||
wraps = f.wraps;
|
||||
}
|
||||
let count = ys.len();
|
||||
let Some(leading) = leading else { return (count, None) };
|
||||
let space = para.map_or(0.0, |p| p - leading);
|
||||
(count, (space > -size * 0.01).then_some(Block { leading, space: space.max(0.0), ys }))
|
||||
}
|
||||
|
||||
/// Lines `nodes` of block `b` as one area type object, aligned left or justified, when laying it
|
||||
/// out breaks its lines where the file does.
|
||||
fn area_type(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>, b: &Block) -> Option<TextObject> {
|
||||
let texts: Vec<(&TextObject, &LineFacts)> = nodes.iter().map(|n| line(n, lines)).collect::<Option<_>>()?;
|
||||
let (first, _) = texts.first()?;
|
||||
let size = first.first_style().size;
|
||||
// Each line's tracking (a line with spaces tracked apart was set by hand).
|
||||
let trackings: Vec<f64> = texts
|
||||
.iter()
|
||||
.map(|(t, _)| {
|
||||
let mut all = t.runs.iter().map(|r| r.style.tracking);
|
||||
let one = all.next()?;
|
||||
all.all(|x| x == one).then_some(one)
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let median = |mut v: Vec<f64>| {
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.get(v.len() / 2).copied()
|
||||
};
|
||||
let wrapped: Vec<f64> = texts.iter().filter(|(_, f)| f.wraps).map(|(_, f)| f.length).collect();
|
||||
let longest = |v: &[f64]| v.iter().copied().fold(0.0, f64::max);
|
||||
let mut tries = vec![];
|
||||
// Wrapped lines all as long: justified (a paragraph's last line aligned left), tracked as the
|
||||
// lines that end paragraphs are (the others are spaced out).
|
||||
let full = longest(&wrapped);
|
||||
if wrapped.len() >= 2 && wrapped.iter().all(|l| (full - l).abs() <= size * 0.02) {
|
||||
let ends: Vec<f64> = texts.iter().zip(&trackings).filter(|((_, f), _)| !f.wraps).map(|(_, t)| *t).collect();
|
||||
let tracking = median(ends).or_else(|| trackings.iter().copied().reduce(f64::min))?;
|
||||
tries.push((Justify::JustifyLeft, full, tracking));
|
||||
}
|
||||
let all: Vec<f64> = texts.iter().map(|(_, f)| f.length).collect();
|
||||
tries.push((Justify::Left, longest(&all) + size * 0.02, median(trackings)?));
|
||||
tries.into_iter().find_map(|(justify, width, tracking)| laid_out(&texts, b, justify, width, tracking))
|
||||
}
|
||||
|
||||
/// The area type of lines `texts` (block `b`): `width` wide, aligned `justify`, its characters
|
||||
/// tracked `tracking`; `None` when it doesn't lay out as the lines did.
|
||||
fn laid_out(texts: &[(&TextObject, &LineFacts)], b: &Block, justify: Justify, width: f64, tracking: f64) -> Option<TextObject> {
|
||||
let (first, _) = texts.first()?;
|
||||
let size = first.first_style().size;
|
||||
// Leading as set, unless it is Auto's (120% of the size).
|
||||
let explicit =
|
||||
(b.leading - size * 1.2).abs() > size * 1e-3 || texts.iter().any(|(t, _)| t.runs.iter().any(|r| (r.style.size - size).abs() > 1e-9));
|
||||
let mut runs: Vec<TextRun> = vec![];
|
||||
for (k, (t, f)) in texts.iter().enumerate() {
|
||||
for r in &t.runs {
|
||||
let mut style = r.style.clone();
|
||||
style.tracking = tracking;
|
||||
style.leading = explicit.then_some((b.leading * 1000.0).round() / 1000.0);
|
||||
match runs.last_mut() {
|
||||
Some(last) if last.style == style => last.text.push_str(&r.text),
|
||||
_ => runs.push(TextRun { text: r.text.clone(), style }),
|
||||
}
|
||||
}
|
||||
// A wrapped line's space, or the end of a paragraph.
|
||||
if k + 1 < texts.len()
|
||||
&& let Some(last) = runs.last_mut()
|
||||
{
|
||||
last.text.push(if f.wraps { ' ' } else { '\n' });
|
||||
}
|
||||
}
|
||||
let mut t = (*first).clone();
|
||||
t.runs = runs;
|
||||
t.para.justify = justify;
|
||||
t.para.space_before = b.space;
|
||||
let last = b.ys.last().copied()?;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// The frame's top puts the first baseline where the file has it.
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, width, last + b.leading.max(size) + size)) };
|
||||
let top = -vectorcraft_text::layout(db, &t).lines.first()?.baseline;
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, top, width, last + b.leading.max(size))) };
|
||||
let laid = vectorcraft_text::layout(db, &t);
|
||||
let plain = t.plain_text();
|
||||
let same = !laid.overflow
|
||||
&& laid.lines.len() == texts.len()
|
||||
&& laid.lines.iter().zip(texts).zip(&b.ys).all(|((l, (line, f)), y)| {
|
||||
let text = plain.get(l.start..l.end).unwrap_or_default();
|
||||
text.trim() == line.plain_text().trim()
|
||||
&& (l.baseline - y).abs() <= size * 0.05
|
||||
&& (justify != Justify::JustifyLeft || !f.wraps || (l.x1 - f.length).abs() <= size * 0.05)
|
||||
});
|
||||
t.cached_bounds = Some(laid.bounds);
|
||||
same.then_some(t)
|
||||
}
|
||||
|
||||
/// Lines of point type in a row among `nodes` that were wrapped in a frame become one area type
|
||||
/// object again (see [`area_type`]).
|
||||
fn join_paragraphs(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
|
||||
let mut i = 0;
|
||||
while i < nodes.len() {
|
||||
let (count, b) = nodes.get(i..).map_or((0, None), |rest| block(rest, lines));
|
||||
let area = b.filter(|_| count > 1).and_then(|b| area_type(nodes.get(i..i + count)?, lines, &b));
|
||||
if let Some(area) = area
|
||||
&& let Some(head) = nodes.get_mut(i)
|
||||
{
|
||||
Arc::make_mut(head).kind = NodeKind::Text(Box::new(area));
|
||||
nodes.drain(i + 1..i + count);
|
||||
i += 1;
|
||||
} else {
|
||||
// Lines that don't make area type: none of them starts it either.
|
||||
i += count.max(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
-12
@@ -9,9 +9,10 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, BezPath, Point, Vec2};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_text::TextLayout;
|
||||
|
||||
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -103,6 +104,8 @@ pub(crate) struct TextLine {
|
||||
next: Point,
|
||||
/// The last glyph's origin.
|
||||
last: Point,
|
||||
/// Where the last glyph that isn't a space ends.
|
||||
visible: Point,
|
||||
/// The last glyph's baseline direction.
|
||||
last_dir: Vec2,
|
||||
/// Each glyph's baseline origin, then where the last one ends.
|
||||
@@ -112,6 +115,24 @@ pub(crate) struct TextLine {
|
||||
upright: Option<Upright>,
|
||||
/// The glyphs make a column (vertical type), each below the last.
|
||||
column: bool,
|
||||
/// Each glyph's ink box as drawn (document space), in drawing order; glyphs without ink are
|
||||
/// left out.
|
||||
pub ink: Vec<Rect>,
|
||||
/// The spaces read from gaps between glyphs: their byte offset in the text, and the gap from
|
||||
/// the end of the glyph before to the start of the next (points).
|
||||
gaps: Vec<(usize, f64)>,
|
||||
}
|
||||
|
||||
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct LineFacts {
|
||||
/// [`TextLine::ink`].
|
||||
pub ink: Vec<Rect>,
|
||||
/// Horizontal point type: it ended in a space (as a line an editor wrapped in a frame does).
|
||||
pub wraps: bool,
|
||||
/// Horizontal point type: how long the file set it, from its first glyph's origin to the end
|
||||
/// of its last glyph that isn't a space (points, along the baseline).
|
||||
pub length: f64,
|
||||
}
|
||||
|
||||
/// The most a glyph on a curve turns from the one before it (about 20°).
|
||||
@@ -119,7 +140,20 @@ const CURVE_TURN_COS: f64 = 0.94;
|
||||
|
||||
impl TextLine {
|
||||
pub fn new(at: Placement, opacity: f32) -> Self {
|
||||
Self { at, opacity, next: at.origin, last: at.origin, last_dir: at.dir, baseline: vec![], runs: vec![], upright: None, column: false }
|
||||
Self {
|
||||
at,
|
||||
opacity,
|
||||
next: at.origin,
|
||||
last: at.origin,
|
||||
visible: at.origin,
|
||||
last_dir: at.dir,
|
||||
baseline: vec![],
|
||||
runs: vec![],
|
||||
upright: None,
|
||||
column: false,
|
||||
ink: vec![],
|
||||
gaps: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Add glyph `text` set vertically (`upright`) if it continues this column (or makes this
|
||||
@@ -171,6 +205,7 @@ impl TextLine {
|
||||
if self.column || self.upright.is_some() {
|
||||
return false;
|
||||
}
|
||||
let bytes: usize = self.runs.iter().map(|(_, t)| t.len()).sum();
|
||||
if let Some((last, run)) = self.runs.last_mut() {
|
||||
let size = self.at.size.max(at.size);
|
||||
let gap = (at.origin - self.next).dot(self.last_dir);
|
||||
@@ -192,6 +227,9 @@ impl TextLine {
|
||||
// A gap wider than a fifth of an em reads as a space.
|
||||
if gap > size * 0.2 && !run.ends_with(' ') && !text.starts_with(' ') {
|
||||
run.push(' ');
|
||||
if on_line {
|
||||
self.gaps.push((bytes, gap));
|
||||
}
|
||||
}
|
||||
if last.takes(look) {
|
||||
run.push_str(text);
|
||||
@@ -202,6 +240,9 @@ impl TextLine {
|
||||
self.runs.push((look.clone(), text.to_string()));
|
||||
}
|
||||
self.next = at.origin + at.dir * advance;
|
||||
if !text.trim().is_empty() {
|
||||
self.visible = self.next;
|
||||
}
|
||||
self.last = at.origin;
|
||||
self.last_dir = at.dir;
|
||||
self.baseline.push(at.origin);
|
||||
@@ -247,10 +288,12 @@ impl TextLine {
|
||||
true
|
||||
}
|
||||
|
||||
/// The point type object and its opacity.
|
||||
pub fn finish(mut self) -> Option<(TextObject, f32)> {
|
||||
/// The point type object, its opacity and what else the line tells.
|
||||
pub fn finish(mut self) -> Option<(TextObject, f32, LineFacts)> {
|
||||
let mut facts = LineFacts { ink: std::mem::take(&mut self.ink), ..LineFacts::default() };
|
||||
// Turned along a curve: type on a path through the glyphs.
|
||||
let path = if self.curved() { self.baseline_path() } else { None };
|
||||
facts.wraps = path.is_none() && self.upright.is_none() && self.runs.last().is_some_and(|(_, t)| t.ends_with(' '));
|
||||
if let Some((_, last)) = self.runs.last_mut() {
|
||||
last.truncate(last.trim_end().len());
|
||||
}
|
||||
@@ -272,7 +315,7 @@ impl TextLine {
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
|
||||
return Some((t, self.opacity));
|
||||
return Some((t, self.opacity, facts));
|
||||
}
|
||||
let (start, along) = match self.upright {
|
||||
// Vertical point type (a column, or a single upright glyph) is anchored at the top
|
||||
@@ -286,26 +329,87 @@ impl TextLine {
|
||||
};
|
||||
// Tracking that makes the line as long as in the file: the PDF placed each glyph, the
|
||||
// layout sets them by their advances. Spread over the gaps between characters (tracking
|
||||
// follows each character, the last one's past the end of the line).
|
||||
let length = (self.next - start).dot(along);
|
||||
// follows each character, the last one's past the end of the line). A horizontal line is
|
||||
// measured to its last glyph that isn't a space: the spaces after it were dropped.
|
||||
let length = (if t.vertical { self.next } else { self.visible } - start).dot(along);
|
||||
if !t.vertical {
|
||||
facts.length = length;
|
||||
}
|
||||
let laid = vectorcraft_text::layout(db, &t);
|
||||
let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum();
|
||||
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let size = self.at.size;
|
||||
let mut bounds = laid.bounds;
|
||||
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
||||
let tracking = ((length - natural) / (chars - 1) as f64 / size * 1000.0).clamp(-1000.0, 1000.0).round();
|
||||
for r in &mut t.runs {
|
||||
r.style.tracking = tracking;
|
||||
}
|
||||
// The spaces read from gaps keep their own place only in text left in the file's order.
|
||||
let total: usize = t.runs.iter().map(|r| r.text.len()).sum();
|
||||
let gaps: Vec<(usize, f64)> =
|
||||
if t.para.direction.is_none() { self.gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] };
|
||||
let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size);
|
||||
t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces);
|
||||
// Tracked: laid out again for its bounds.
|
||||
bounds = vectorcraft_text::layout(db, &t).bounds;
|
||||
}
|
||||
t.cached_bounds = Some(bounds);
|
||||
Some((t, self.opacity))
|
||||
Some((t, self.opacity, facts))
|
||||
}
|
||||
}
|
||||
|
||||
/// The tracking (thousandths of an em) that sets a line of `chars` characters, laid out untracked
|
||||
/// as `laid`, `extra` points longer, as the file set it: the same after every character, except
|
||||
/// the spaces read from gaps (`gaps`: byte offset, gap in points) where that would put the glyphs
|
||||
/// after them more than a tenth of an em off (a barcode's digits, set in groups apart). Those take
|
||||
/// their own tracking, given with their byte offsets.
|
||||
fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, size: f64) -> (f64, Vec<(usize, f64)>) {
|
||||
let pairs = chars.saturating_sub(1).max(1) as f64;
|
||||
// The space and the character before it take `need` (the gap less the space) between them;
|
||||
// tracking gives them twice its share.
|
||||
let space = |b: usize| laid.glyphs.iter().find(|g| g.byte == b).map(|g| g.advance);
|
||||
let needs: Vec<(usize, f64)> = gaps.iter().filter_map(|&(b, gap)| Some((b, gap - space(b)?))).collect();
|
||||
let mut own: Vec<(usize, f64)> = vec![];
|
||||
let mut tracking = extra / pairs;
|
||||
// Each space taken apart changes the others' share: until no more are.
|
||||
loop {
|
||||
let more: Vec<(usize, f64)> =
|
||||
needs.iter().filter(|(b, need)| !own.iter().any(|(o, _)| o == b) && (need - 2.0 * tracking).abs() > size * 0.1).copied().collect();
|
||||
if more.is_empty() {
|
||||
break;
|
||||
}
|
||||
own.extend(more);
|
||||
let rest = pairs - 2.0 * own.len() as f64;
|
||||
tracking = if rest >= 1.0 { (extra - own.iter().map(|(_, need)| need).sum::<f64>()) / rest } else { 0.0 };
|
||||
}
|
||||
own.sort_by_key(|(b, _)| *b);
|
||||
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round();
|
||||
(em(tracking), own.into_iter().map(|(b, need)| (b, em(need - tracking))).collect())
|
||||
}
|
||||
|
||||
/// `runs` with tracking `tracking`, and each space at a byte offset of `spaces` (in order) a run
|
||||
/// of its own with the tracking given with it.
|
||||
fn tracked(runs: Vec<TextRun>, tracking: f64, spaces: &[(usize, f64)]) -> Vec<TextRun> {
|
||||
let mut out = Vec::with_capacity(runs.len() + 2 * spaces.len());
|
||||
let mut off = 0;
|
||||
for r in runs {
|
||||
let style = CharStyle { tracking, ..r.style };
|
||||
let end = off + r.text.len();
|
||||
let mut from = 0;
|
||||
for &(b, own) in spaces.iter().filter(|(b, _)| (off..end).contains(b)) {
|
||||
let at = b - off;
|
||||
let (Some(before), Some(space)) = (r.text.get(from..at), r.text.get(at..at + 1)) else { continue };
|
||||
if !before.is_empty() {
|
||||
out.push(TextRun { text: before.to_string(), style: style.clone() });
|
||||
}
|
||||
out.push(TextRun { text: space.to_string(), style: CharStyle { tracking: own, ..style.clone() } });
|
||||
from = at + 1;
|
||||
}
|
||||
if let Some(rest) = r.text.get(from..).filter(|t| !t.is_empty()) {
|
||||
out.push(TextRun { text: rest.to_string(), style });
|
||||
}
|
||||
off = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn round(v: f64) -> f64 {
|
||||
(v * 1000.0).round() / 1000.0
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ mod images;
|
||||
mod import;
|
||||
mod import_color;
|
||||
mod import_image;
|
||||
mod import_lines;
|
||||
mod import_mask;
|
||||
mod import_scan;
|
||||
mod import_shading;
|
||||
@@ -231,6 +232,8 @@ mod tests_import_fidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_import_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_import_lines;
|
||||
#[cfg(test)]
|
||||
mod tests_import_options;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
|
||||
@@ -242,11 +242,13 @@ fn text_becomes_point_type() {
|
||||
// The baseline origin, in y-down page space.
|
||||
let o = t[0].xf * kurbo::Point::ORIGIN;
|
||||
assert!((o.x - 10.0).abs() < 0.01 && (o.y - 70.0).abs() < 0.01, "{o:?}");
|
||||
// A gap of an em between words reads as a space; another font on the line is another run.
|
||||
// A gap of an em between words reads as a space, tracked on its own to keep the gap's width
|
||||
// (#508); another font on the line is another run.
|
||||
assert_eq!(text(&t[1]), "Next line in bold");
|
||||
let runs: Vec<(&str, &str)> = t[1].runs.iter().map(|r| (r.text.as_str(), r.style.font_style.as_str())).collect();
|
||||
assert_eq!(runs, [("Next line", "Regular"), (" in bold", "Bold")]);
|
||||
assert_eq!(t[1].runs[1].style.fill.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert_eq!(runs, [("Next", "Regular"), (" ", "Regular"), ("line", "Regular"), (" in bold", "Bold")]);
|
||||
assert!(t[1].runs[1].style.tracking > t[1].runs[0].style.tracking + 100.0, "{:?}", t[1].runs);
|
||||
assert_eq!(t[1].runs[3].style.fill.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("outlines")), "{:?}", r.warnings);
|
||||
// As outlines: paths, no type.
|
||||
let r = open_with(&bytes, |o| o.text_as = TextAs::Outlines);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! What a file's lines of type were (#508): the layer art ending a group's marked content belongs
|
||||
//! to, layers ordered as the pages mark them, a stroke written as stroked glyph outlines, spaces
|
||||
//! read from wide gaps, and lines wrapped in a frame rebuilt as area type.
|
||||
|
||||
use kurbo::{BezPath, PathEl, Point, Shape};
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
|
||||
|
||||
use crate::*;
|
||||
|
||||
const HELVETICA: &str = "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>";
|
||||
|
||||
/// Groups "A" and "B" (A painted first) for a file of `pages` pages, and the catalog entries.
|
||||
fn groups(pages: usize) -> ([String; 2], String, String) {
|
||||
let (a, b) = (first_extra(pages), first_extra(pages) + 1);
|
||||
let objs = ["<< /Type /OCG /Name (A) >>".to_string(), "<< /Type /OCG /Name (B) >>".to_string()];
|
||||
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /Order [{b} 0 R {a} 0 R] >> >> ");
|
||||
let resources = format!("/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> {HELVETICA}");
|
||||
(objs, catalog, resources)
|
||||
}
|
||||
|
||||
/// A file of 100 × 100 pt pages drawing `contents`, with groups A (`/MC0`) and B (`/MC1`).
|
||||
fn layered(contents: &[&str]) -> Document {
|
||||
let (objs, catalog, resources) = groups(contents.len());
|
||||
let pages: Vec<PdfPage> = contents.iter().map(|c| PdfPage { resources: resources.clone(), ..PdfPage::new(100.0, 100.0, c) }).collect();
|
||||
import(&pdf_with_catalog(&pages, &[&objs[0], &objs[1]], &catalog, None)).unwrap()
|
||||
}
|
||||
|
||||
/// The layers' names, bottom first, and the kinds of their art.
|
||||
fn layers(d: &Document) -> Vec<(String, Vec<&'static str>)> {
|
||||
let kind = |n: &std::sync::Arc<Node>| match n.kind {
|
||||
NodeKind::Text(_) => "text",
|
||||
NodeKind::Group { .. } => "group",
|
||||
_ => "path",
|
||||
};
|
||||
d.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.children().map_or(vec![], |c| c.iter().map(kind).collect()))).collect()
|
||||
}
|
||||
|
||||
fn texts(d: &Document) -> Vec<TextObject> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
v.push((**t).clone());
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
fn named(layers: &[(&str, &[&'static str])]) -> Vec<(String, Vec<&'static str>)> {
|
||||
layers.iter().map(|(n, k)| (n.to_string(), k.to_vec())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_ending_a_layers_marked_content_stays_in_its_layer() {
|
||||
// The line is still being gathered when the group's sequence ends.
|
||||
let d = layered(&["/OC /MC0 BDC 0 0 1 rg 0 0 10 10 re f BT /F1 12 Tf 10 50 Td (Lead) Tj ET EMC /OC /MC1 BDC EMC"]);
|
||||
assert_eq!(layers(&d), named(&[("A", &["path", "text"]), ("B", &[])]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_ending_in_a_later_group_stays_with_its_art() {
|
||||
// The clip opens in A, its art is drawn in A, and it closes after B's (empty) sequence.
|
||||
let d = layered(&["/OC /MC0 BDC q 0 0 50 50 re W n 1 0 0 rg 40 40 20 20 re f EMC /OC /MC1 BDC EMC Q"]);
|
||||
assert_eq!(layers(&d), named(&[("A", &["group"]), ("B", &[])]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_stack_as_the_pages_mark_them_when_one_first_paints_on_a_later_page() {
|
||||
// A, the bottom layer, is empty on page 1 and has art on page 2 only.
|
||||
let d = layered(&["/OC /MC0 BDC EMC /OC /MC1 BDC 1 0 0 rg 0 0 10 10 re f EMC", "/OC /MC0 BDC 0 0 1 rg 0 0 10 10 re f EMC /OC /MC1 BDC EMC"]);
|
||||
assert_eq!(layers(&d), named(&[("A", &["path"]), ("B", &["path"])]));
|
||||
// A group marked with no art on any page is an empty layer in its place.
|
||||
let d = layered(&["/OC /MC0 BDC EMC /OC /MC1 BDC 1 0 0 rg 0 0 10 10 re f EMC"]);
|
||||
assert_eq!(layers(&d), named(&[("A", &[]), ("B", &["path"])]));
|
||||
}
|
||||
|
||||
/// A 100 × 100 pt page drawing `content` in Helvetica.
|
||||
fn page(content: &str) -> Vec<u8> {
|
||||
vectorcraft_testkit::pdf::pdf(&[PdfPage { resources: HELVETICA.into(), ..PdfPage::new(100.0, 100.0, content) }], None)
|
||||
}
|
||||
|
||||
/// The outline of glyph `c` drawn at 24 pt from (x, 50), in PDF operators (y up), stroked.
|
||||
fn outline(c: char, x: f64) -> String {
|
||||
let opts = ImportOptions { text_as: TextAs::Outlines, ..Default::default() };
|
||||
let d = import_with_report(&page(&format!("BT /F1 24 Tf {x} 50 Td ({c}) Tj ET")), &opts).unwrap().document;
|
||||
let mut p: Option<BezPath> = None;
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Path { path, .. } = &n.kind {
|
||||
p = Some(path.to_bezpath());
|
||||
}
|
||||
});
|
||||
let pt = |q: Point| format!("{:.3} {:.3}", q.x, 100.0 - q.y);
|
||||
let ops: String = p
|
||||
.unwrap()
|
||||
.elements()
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
PathEl::MoveTo(a) => format!("{} m ", pt(*a)),
|
||||
PathEl::LineTo(a) => format!("{} l ", pt(*a)),
|
||||
PathEl::QuadTo(a, b) => format!("{} {} {} c ", pt(*a), pt(*b), pt(*b)),
|
||||
PathEl::CurveTo(a, b, c) => format!("{} {} {} c ", pt(*a), pt(*b), pt(*c)),
|
||||
PathEl::ClosePath => "h ".into(),
|
||||
})
|
||||
.collect();
|
||||
ops + "S "
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_written_as_stroked_glyph_outlines_is_the_types_stroke() {
|
||||
// "Hi": the H is 722 units wide in Helvetica.
|
||||
let strokes = format!("0 0 1 RG 0.5 w {}{}", outline('H', 10.0), outline('i', 10.0 + 0.722 * 24.0));
|
||||
let text = "BT 1 0 0 rg /F1 24 Tf 10 50 Td (Hi) Tj ET ";
|
||||
for (content, below) in [(format!("{text}{strokes}"), false), (format!("{strokes}{text}"), true)] {
|
||||
let d = import(&page(&content)).unwrap();
|
||||
let art = d.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "one object: {art:?}");
|
||||
let NodeKind::Text(t) = &art[0].kind else { panic!("{:?}", art[0].kind) };
|
||||
assert_eq!(t.plain_text(), "Hi");
|
||||
assert_eq!(t.first_style().fill.color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let ap = &art[0].appearance;
|
||||
let [AppearanceItem::Stroke(st)] = ap.items.as_slice() else { panic!("{ap:?}") };
|
||||
assert_eq!(st.paint.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert!((st.width - 0.5).abs() < 1e-6);
|
||||
assert_eq!(ap.contents_at(), usize::from(below), "a stroke drawn first paints below the characters");
|
||||
}
|
||||
// A stroke that isn't on the glyphs stays a path.
|
||||
let d = import(&page(&format!("{text}0 0 1 RG 0.5 w {}", outline('H', 40.0)))).unwrap();
|
||||
assert_eq!(d.layers[0].children().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3.
|
||||
fn source_sans(lines: &[(f32, f32, f32, &str)]) -> Vec<u8> {
|
||||
crate::tests_live_text::text_pdf(lines, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_read_from_wide_gaps_keep_the_glyphs_after_them_in_place() {
|
||||
// A barcode's digits in three groups, set apart by more than a space.
|
||||
let (x1, x2) = (29.0, 69.0);
|
||||
let bytes = source_sans(&[(20.0, 50.0, 10.0, "3"), (x1, 50.0, 10.0, "533121"), (x2, 50.0, 10.0, "010629")]);
|
||||
let t = texts(&import(&bytes).unwrap());
|
||||
assert_eq!(t.len(), 1);
|
||||
assert_eq!(t[0].plain_text(), "3 533121 010629");
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]);
|
||||
let at = |byte: usize| (t[0].xf * laid.glyphs.iter().find(|g| g.byte == byte).unwrap().origin).x;
|
||||
assert!((at(2) - f64::from(x1)).abs() < 0.05, "the second group starts where the file has it: {} vs {x1}", at(2));
|
||||
assert!((at(9) - f64::from(x2)).abs() < 0.05, "the third group starts where the file has it: {} vs {x2}", at(9));
|
||||
}
|
||||
|
||||
/// A document of area type `text` (Source Sans 3, 10 pt) `width` wide at (20, 30).
|
||||
fn area_doc(text: &str, justify: Justify, width: f64, space_before: f64) -> Document {
|
||||
let style = CharStyle { size: 10.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
let mut t = TextObject::point(Point::ORIGIN, text, style);
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, width, 200.0)) };
|
||||
t.xf = Affine::translate((20.0, 30.0));
|
||||
t.para.justify = justify;
|
||||
t.para.space_before = space_before;
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
let l = d.layers[0].id;
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// `d` exported (real text) and imported again.
|
||||
fn round_trip(d: &Document) -> Document {
|
||||
let settings: PdfSettings = serde_json::from_value(json!({"compression": {"compressText": false}, "advanced": {"outlineText": false}})).unwrap();
|
||||
let bytes = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
|
||||
import(&bytes).unwrap()
|
||||
}
|
||||
|
||||
const PROSE: &str = "Stop losing your sinkers on the cast: the clip holds the lead until a snag pulls it free, \
|
||||
so the fish swims on without the weight.\nIt works on every running rig and leaves the line clear.";
|
||||
|
||||
#[test]
|
||||
fn lines_wrapped_in_a_frame_come_back_as_area_type() {
|
||||
for (justify, space) in [(Justify::Left, 0.0), (Justify::JustifyLeft, 4.0)] {
|
||||
let d = round_trip(&area_doc(PROSE, justify, 120.0, space));
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 1, "{justify:?}: one object, not a line each: {:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>());
|
||||
assert_eq!(t[0].plain_text(), PROSE);
|
||||
assert_eq!(t[0].para.justify, justify);
|
||||
assert!((t[0].para.space_before - space).abs() < 0.01, "{}", t[0].para.space_before);
|
||||
let TextKind::Area { frame } = &t[0].kind else { panic!("{:?}", t[0].kind) };
|
||||
let w = frame.to_bezpath().bounding_box().width();
|
||||
assert!(w <= 120.5 && w > 100.0, "{w}");
|
||||
let origin = t[0].xf * Point::ZERO;
|
||||
assert!((origin.x - 20.0).abs() < 0.05, "{origin:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_that_were_not_wrapped_stay_point_type() {
|
||||
// A list: each line its own, none ending in a space.
|
||||
let bytes = source_sans(&[(20.0, 50.0, 10.0, "Hooks"), (20.0, 62.0, 10.0, "Swivels"), (20.0, 74.0, 10.0, "Leads")]);
|
||||
assert_eq!(texts(&import(&bytes).unwrap()).len(), 3);
|
||||
// Wrapped lines whose breaks a frame doesn't give again (a line far short of the others).
|
||||
let bytes = source_sans(&[(20.0, 50.0, 10.0, "One two three four five six "), (20.0, 62.0, 10.0, "a "), (20.0, 74.0, 10.0, "seven")]);
|
||||
assert_eq!(texts(&import(&bytes).unwrap()).len(), 3);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const SOURCE_SERIF: &[u8] = include_bytes!("../../../assets/fonts/SourceSerif4-R
|
||||
|
||||
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3, and the
|
||||
/// same rotated 90° when `rotated`.
|
||||
fn text_pdf(lines: &[(f32, f32, f32, &str)], rotated: bool) -> Vec<u8> {
|
||||
pub(crate) fn text_pdf(lines: &[(f32, f32, f32, &str)], rotated: bool) -> Vec<u8> {
|
||||
let mut pdf = krilla::Document::new();
|
||||
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
|
||||
let mut s = page.surface();
|
||||
@@ -269,7 +269,7 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
|
||||
let mut line = TextLine::new(at(100.0, 100.0), 1.0);
|
||||
assert!(line.push_upright(&look, at(100.0, 100.0), 1.0, Upright { top: Point::new(110.0, 82.0) }, "§"));
|
||||
assert!(!line.push(&look, at(130.0, 100.0), 1.0, 10.0, "a"), "a horizontal glyph starts another line");
|
||||
let (t, _) = line.finish().unwrap();
|
||||
let (t, ..) = line.finish().unwrap();
|
||||
assert!(t.vertical, "one upright glyph is vertical type");
|
||||
assert_eq!(t.plain_text(), "§");
|
||||
}
|
||||
@@ -294,7 +294,7 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
|
||||
let y = 100.0 + 30.0 * i as f64;
|
||||
assert!(line.push_upright(&look, at(y), 1.0, Upright { top: Point::new(110.0, y - 18.0) }, c));
|
||||
}
|
||||
let (t, _) = line.finish().unwrap();
|
||||
let (t, ..) = line.finish().unwrap();
|
||||
assert!(t.vertical);
|
||||
assert_eq!(t.plain_text(), "§§§", "letter spacing isn't a space");
|
||||
}
|
||||
|
||||
@@ -621,7 +621,9 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option<Corners> {
|
||||
let sweep = (pie.1 - pie.0).abs();
|
||||
(sweep < 1e-6 || (sweep - 360.0).abs() < 1e-6).then_some(Corners::Ellipse)
|
||||
}
|
||||
Some(LiveShape::Rectangle { .. } | LiveShape::Ellipse { .. } | LiveShape::Polygon { .. } | LiveShape::Line { .. }) => None,
|
||||
Some(
|
||||
LiveShape::Rectangle { .. } | LiveShape::Ellipse { .. } | LiveShape::Polygon { .. } | LiveShape::Line { .. } | LiveShape::Path { .. },
|
||||
) => None,
|
||||
None => is_upright_rectangle(path).then_some(Corners::Square),
|
||||
}
|
||||
}
|
||||
|
||||
+301
-143
@@ -1,187 +1,231 @@
|
||||
//! Live Corners: the widgets inside the corners of a selected live rectangle. Dragging one rounds
|
||||
//! (or sharpens) the corners whose widgets show together: all four when the whole shape is
|
||||
//! selected, those with a selected anchor when Direct Selection picked some. Alt-clicking a widget
|
||||
//! cycles their corner kind (round, inverted round, chamfer); double-clicking one opens the
|
||||
//! Corners dialog. The Selection and Direct Selection tools share this, and the canvas draws the
|
||||
//! widgets from the same geometry.
|
||||
//! Live Corners: the widgets inside the corners of a selected path. The Selection tool shows them
|
||||
//! on a live rectangle or polygon, Direct Selection on any path (a star, a pen path) at each corner
|
||||
//! anchor: one without handles between two straight sides. Dragging one rounds (or sharpens) the
|
||||
//! corners whose widgets show together: every corner when the whole path is selected, those with a
|
||||
//! selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
|
||||
//! kind (round, inverted round, chamfer); double-clicking one opens the Corners dialog. The
|
||||
//! Selection and Direct Selection tools share this, and the canvas draws the widgets from the same
|
||||
//! geometry.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Document, LiveShape, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2};
|
||||
use vectorcraft_doc::{Document, LiveCorners, LiveShape, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::corners::{Corner, cut_corners};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
|
||||
|
||||
use crate::{Action, Overlay, PointerEvent, ToolContext};
|
||||
|
||||
/// The dialog a double-click on a corner widget opens: `{id, corners}`.
|
||||
pub const DIALOG: &str = "corners";
|
||||
|
||||
/// Widgets sit at least this far inside their corner (screen px), further in once the radius is.
|
||||
/// Widgets sit at least this far inside their corner along each side of a right angle (screen px;
|
||||
/// √2 times it along the bisector, at any angle), further in once the radius is.
|
||||
const MIN_INSET_PX: f64 = 10.0;
|
||||
/// Shapes whose shorter side is smaller than this on screen (px) hide their widgets.
|
||||
/// Corners with a side shorter than this on screen (px) hide their widgets.
|
||||
const MIN_SIDE_PX: f64 = 3.0 * MIN_INSET_PX;
|
||||
/// Corners in radii order (top-left, top-right, bottom-right, bottom-left): position as factors
|
||||
/// of (w, h) and the inward diagonal.
|
||||
const CORNERS: [((f64, f64), (f64, f64)); 4] =
|
||||
[((0.0, 0.0), (1.0, 1.0)), ((1.0, 0.0), (-1.0, 1.0)), ((1.0, 1.0), (-1.0, -1.0)), ((0.0, 1.0), (1.0, -1.0))];
|
||||
|
||||
/// The corner widgets of a selection that is exactly one editable live rectangle.
|
||||
/// One corner's widget.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Widget {
|
||||
/// The corner, in the corners' own space.
|
||||
corner: Corner,
|
||||
/// The radius it is set to (document units) and its kind.
|
||||
radius: f64,
|
||||
kind: CornerKind,
|
||||
/// The angle between its sides in the document, in degrees.
|
||||
angle: f64,
|
||||
/// The widget's centre in the document.
|
||||
at: Point,
|
||||
}
|
||||
|
||||
/// The corner widgets of a selection that is exactly one editable path with corners.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CornerWidgets {
|
||||
pub id: NodeId,
|
||||
w: f64,
|
||||
h: f64,
|
||||
radii: [f64; 4],
|
||||
kinds: [CornerKind; 4],
|
||||
/// Maps the corners' space into the document (a live rectangle's own; else none).
|
||||
xf: Affine,
|
||||
/// Widget centres in document coordinates, in radii order.
|
||||
pub points: [Point; 4],
|
||||
/// The corners whose widgets show (and which a drag edits).
|
||||
pub shown: [bool; 4],
|
||||
/// The widgets that show (and which a drag edits), in anchor order.
|
||||
widgets: Vec<Widget>,
|
||||
/// Every corner of the path shows its widget.
|
||||
all: bool,
|
||||
}
|
||||
|
||||
impl CornerWidgets {
|
||||
/// The widgets at `zoom` (screen px per document point), if the selection has them.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> {
|
||||
/// The widgets at `zoom` (screen px per document point), if the selection has them: on any
|
||||
/// path with Direct Selection (`any_path`), on live rectangles and polygons with Selection.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64, any_path: bool) -> Option<Self> {
|
||||
let [id] = selection.objects[..] else { return None };
|
||||
if !doc.is_editable(id) {
|
||||
return None;
|
||||
}
|
||||
let Some(NodeKind::Path { live: Some(live), .. }) = doc.node(id).map(|n| &n.kind) else { return None };
|
||||
// In document units, as `object.setLiveShape` edits them.
|
||||
let LiveShape::Rectangle { w, h, radii, kinds, xf } = live.folded() else { return None };
|
||||
// Screen pixels per shape unit along each side (the shape may be scaled or skewed).
|
||||
let c = xf.as_coeffs();
|
||||
let (px, py) = (Vec2::new(c[0], c[1]).hypot() * zoom, Vec2::new(c[2], c[3]).hypot() * zoom);
|
||||
if xf.determinant().abs() < 1e-12 || (w * px).min(h * py) < MIN_SIDE_PX {
|
||||
let NodeKind::Path { path, live, .. } = &doc.node(id)?.kind else { return None };
|
||||
if !any_path && !matches!(live, Some(LiveShape::Rectangle { .. } | LiveShape::Polygon { .. })) {
|
||||
return None;
|
||||
}
|
||||
// Each widget sits on its corner's diagonal, at the centre of the arc as drawn.
|
||||
let points = std::array::from_fn(|k| {
|
||||
let ((fx, fy), (sx, sy)) = CORNERS[k];
|
||||
let r = shapes::fitted_corner_radius(w, h, radii[k]);
|
||||
let ix = r.max(MIN_INSET_PX / px).min(w / 2.0);
|
||||
let iy = r.max(MIN_INSET_PX / py).min(h / 2.0);
|
||||
xf * Point::new(fx * w + sx * ix, fy * h + sy * iy)
|
||||
});
|
||||
Some(Self { id, w, h, radii, kinds, xf, points, shown: live.picked_corners(selection.partial(id)) })
|
||||
let lc = LiveCorners::new(path, live.as_ref())?;
|
||||
let det = lc.xf.determinant().abs();
|
||||
if det < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
// Screen pixels per unit of the corners' space (a rectangle's may be skewed).
|
||||
let scale = zoom * det.sqrt();
|
||||
let picked = lc.picked(selection.partial(id));
|
||||
let widgets: Vec<_> = lc
|
||||
.corners
|
||||
.iter()
|
||||
.filter(|c| picked.contains(&c.index) && c.lu.min(c.lv) * scale >= MIN_SIDE_PX)
|
||||
.map(|c| {
|
||||
// On the bisector at the centre of the arc as drawn, at least the minimum inset
|
||||
// in and at most half the shorter side.
|
||||
let radius = lc.radius(c.index);
|
||||
let k = (c.fitted(radius) / c.sin()).max(MIN_INSET_PX / (scale * (1.0 + c.cos()).sqrt())).min(c.lu.min(c.lv) / 2.0);
|
||||
Widget { corner: *c, radius, kind: lc.kind(c.index), angle: angle_in(lc.xf, c), at: lc.xf * c.on_bisector(k) }
|
||||
})
|
||||
.collect();
|
||||
let all = widgets.len() == lc.corners.len();
|
||||
(!widgets.is_empty()).then_some(Self { id, xf: lc.xf, widgets, all })
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display → Hide Corner Widget for angles greater than: the widgets of
|
||||
/// corners wider than `max` degrees (a skewed rectangle's obtuse ones) hide; none if all do.
|
||||
/// corners wider than `max` degrees (in the document) hide; none if all do.
|
||||
pub fn within_angle(mut self, max: f64) -> Option<Self> {
|
||||
// The angle between the shape's sides where they meet at the top-left corner; the
|
||||
// top-right and bottom-left corners are its supplement.
|
||||
let c = self.xf.as_coeffs();
|
||||
let (u, v) = (Vec2::new(c[0], c[1]), Vec2::new(c[2], c[3]));
|
||||
let a = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
|
||||
for (k, shown) in self.shown.iter_mut().enumerate() {
|
||||
let angle = if k % 2 == 0 { a } else { 180.0 - a };
|
||||
*shown &= angle <= max + 1e-9;
|
||||
}
|
||||
self.shown.contains(&true).then_some(self)
|
||||
let n = self.widgets.len();
|
||||
self.widgets.retain(|w| w.angle <= max + 1e-9);
|
||||
self.all &= self.widgets.len() == n;
|
||||
(!self.widgets.is_empty()).then_some(self)
|
||||
}
|
||||
|
||||
/// The widgets the active tool can drag (View → Show Corner Widget on).
|
||||
pub fn for_tool(cx: &ToolContext) -> Option<Self> {
|
||||
if !cx.corner_widgets {
|
||||
/// The widgets on show: [`Self::of`] with View → Show Corner Widget `on`, less those wider
|
||||
/// than `max_angle` ([`Self::within_angle`]).
|
||||
pub fn showing(doc: &Document, selection: &Selection, zoom: f64, any_path: bool, on: bool, max_angle: f64) -> Option<Self> {
|
||||
if !on {
|
||||
return None;
|
||||
}
|
||||
Self::of(cx.doc, cx.selection, cx.zoom)?.within_angle(cx.corner_widget_max_angle)
|
||||
Self::of(doc, selection, zoom, any_path)?.within_angle(max_angle)
|
||||
}
|
||||
|
||||
/// The widgets the active tool can drag (Direct Selection: `any_path`).
|
||||
pub fn for_tool(cx: &ToolContext, any_path: bool) -> Option<Self> {
|
||||
Self::showing(cx.doc, cx.selection, cx.zoom, any_path, cx.corner_widgets, cx.corner_widget_max_angle)
|
||||
}
|
||||
|
||||
/// The centres of the widgets that show.
|
||||
pub fn visible(&self) -> impl Iterator<Item = Point> + '_ {
|
||||
self.points.iter().zip(self.shown).filter(|(_, on)| *on).map(|(p, _)| *p)
|
||||
self.widgets.iter().map(|w| w.at)
|
||||
}
|
||||
|
||||
/// The indices of the shown corners.
|
||||
fn shown_indices(&self) -> Value {
|
||||
(0..4).filter(|k| self.shown[*k]).collect()
|
||||
/// The corners whose widgets show: anchor indices of the path's uncut outline.
|
||||
pub fn corners(&self) -> Vec<usize> {
|
||||
self.widgets.iter().map(|w| w.corner.index).collect()
|
||||
}
|
||||
|
||||
/// Index of the shown widget nearest to `p` within `tol` (document units).
|
||||
pub fn hit(&self, p: Point, tol: f64) -> Option<usize> {
|
||||
let d = |k: &usize| self.points[*k].distance(p);
|
||||
(0..4).filter(|k| self.shown[*k] && d(k) <= tol).min_by(|a, b| d(a).total_cmp(&d(b)))
|
||||
/// The widget nearest to `p` within `tol` (document units).
|
||||
fn hit(&self, p: Point, tol: f64) -> Option<usize> {
|
||||
let d = |k: &usize| self.widgets.get(*k).map_or(f64::INFINITY, |w| w.at.distance(p));
|
||||
(0..self.widgets.len()).filter(|k| d(k) <= tol).min_by(|a, b| d(a).total_cmp(&d(b)))
|
||||
}
|
||||
|
||||
/// `object.setLiveShape` params setting `key` on the shown corners (all four unless some are
|
||||
/// hidden, which `corners` then leaves out).
|
||||
/// `object.setLiveShape` params setting `key` on the shown corners (every corner unless some
|
||||
/// are hidden, which `corners` then leaves out).
|
||||
fn command(&self, key: &str, value: Value) -> Value {
|
||||
let mut p = Map::new();
|
||||
p.insert("id".into(), json!(self.id.0));
|
||||
p.insert(key.into(), value);
|
||||
if self.shown != [true; 4] {
|
||||
p.insert("corners".into(), self.shown_indices());
|
||||
if !self.all {
|
||||
p.insert("corners".into(), json!(self.corners()));
|
||||
}
|
||||
Value::Object(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` over a corner widget the active tool would drag?
|
||||
pub fn over_widget(cx: &ToolContext, p: Point) -> bool {
|
||||
CornerWidgets::for_tool(cx).and_then(|w| w.hit(p, cx.tol(5.0))).is_some()
|
||||
/// The angle between corner `c`'s sides once `xf` maps them into the document, in degrees.
|
||||
fn angle_in(xf: Affine, c: &Corner) -> f64 {
|
||||
let [a, b, cc, d, _, _] = xf.as_coeffs();
|
||||
let map = |v: Vec2| Vec2::new(a * v.x + cc * v.y, b * v.x + d * v.y);
|
||||
let (u, v) = (map(c.u), map(c.v));
|
||||
u.cross(v).abs().atan2(u.dot(v)).to_degrees()
|
||||
}
|
||||
|
||||
/// Is `p` over a corner widget the active tool would drag (Direct Selection: `any_path`)?
|
||||
pub fn over_widget(cx: &ToolContext, p: Point, any_path: bool) -> bool {
|
||||
CornerWidgets::for_tool(cx, any_path).and_then(|w| w.hit(p, cx.tol(5.0))).is_some()
|
||||
}
|
||||
|
||||
/// A double-click on a corner widget opens the Corners dialog for the shown corners.
|
||||
pub fn double_click(cx: &ToolContext, p: Point) -> Option<Action> {
|
||||
let w = CornerWidgets::for_tool(cx)?;
|
||||
pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option<Action> {
|
||||
let w = CornerWidgets::for_tool(cx, any_path)?;
|
||||
w.hit(p, cx.tol(5.0))?;
|
||||
Some(Action::Dialog(DIALOG.into(), json!({ "id": w.id.0, "corners": w.shown_indices() })))
|
||||
Some(Action::Dialog(DIALOG.into(), json!({ "id": w.id.0, "corners": w.corners() })))
|
||||
}
|
||||
|
||||
/// Dragging a corner widget: the radius follows the pointer along the corner's diagonal. An
|
||||
/// Dragging a corner widget: the radius follows the pointer along the corner's bisector. An
|
||||
/// Alt-click (no drag) cycles the corner kind instead.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CornerDrag {
|
||||
widgets: CornerWidgets,
|
||||
corner: usize,
|
||||
/// The dragged widget (index into the shown ones).
|
||||
widget: usize,
|
||||
/// The path's uncut outline with its corners' radii and kinds (to outline the corners at
|
||||
/// their largest).
|
||||
base: PathData,
|
||||
radii: Vec<f64>,
|
||||
kinds: Vec<CornerKind>,
|
||||
start: Point,
|
||||
began: bool,
|
||||
alt: bool,
|
||||
radius: f64,
|
||||
/// The pointer went past fully round: the corners are at their largest radius.
|
||||
/// The pointer went past fully round: the dragged corner is at its largest radius.
|
||||
at_limit: bool,
|
||||
at: Point,
|
||||
}
|
||||
|
||||
impl CornerDrag {
|
||||
/// Start a drag when the press `ev` is on a widget of the selection.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let (widgets, p) = (CornerWidgets::for_tool(cx)?, ev.pos);
|
||||
let corner = widgets.hit(p, cx.tol(5.0))?;
|
||||
Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at_limit: false, at: p })
|
||||
/// Start a drag when the press `ev` is on a widget of the selection (Direct Selection:
|
||||
/// `any_path`).
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent, any_path: bool) -> Option<Self> {
|
||||
let (widgets, p) = (CornerWidgets::for_tool(cx, any_path)?, ev.pos);
|
||||
let widget = widgets.hit(p, cx.tol(5.0))?;
|
||||
let radius = widgets.widgets.get(widget)?.radius;
|
||||
let lc = cx.doc.node(widgets.id).and_then(LiveCorners::of)?;
|
||||
let n = lc.base.anchor_count();
|
||||
let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect());
|
||||
let base = lc.base.into_owned();
|
||||
Some(Self { widgets, widget, base, radii, kinds, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p })
|
||||
}
|
||||
|
||||
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
|
||||
/// corner's inward diagonal (in the shape's own units), from square to fully round; and
|
||||
/// whether the travel reached fully round (the limit every corner shares).
|
||||
/// corner's bisector (in the corners' own units), from square to fully round; and whether the
|
||||
/// travel reached fully round.
|
||||
fn radius_at(&self, p: Point) -> (f64, bool) {
|
||||
let w = &self.widgets;
|
||||
let inv = w.xf.inverse();
|
||||
let d = inv * p - inv * self.start;
|
||||
let (_, (sx, sy)) = CORNERS[self.corner];
|
||||
let max = shapes::max_corner_radius(w.w, w.h);
|
||||
let r = shapes::fitted_corner_radius(w.w, w.h, w.radii[self.corner]) + (d.x * sx + d.y * sy) / 2.0;
|
||||
let Some(w) = self.widgets.widgets.get(self.widget) else { return (self.radius, self.at_limit) };
|
||||
let inv = self.widgets.xf.inverse();
|
||||
let max = w.corner.max_radius();
|
||||
let r = w.corner.fitted(w.radius) + w.corner.radius_change(inv * p - inv * self.start);
|
||||
(r.min(max).max(0.0), max > 0.0 && r >= max)
|
||||
}
|
||||
|
||||
/// The edited corners' cuts at the dragged radius, in document coordinates.
|
||||
fn edited_corners(&self) -> BezPath {
|
||||
let w = &self.widgets;
|
||||
let rect = Rect::new(0.0, 0.0, w.w, w.h);
|
||||
let radii = std::array::from_fn(|k| if w.shown[k] { self.radius } else { w.radii[k] });
|
||||
let corner_of = shapes::rectangle_anchor_corners(rect, radii);
|
||||
let path = shapes::rectangle_with_corners(rect, radii, w.kinds).transformed(w.xf);
|
||||
/// The cuts of the edited corners that are at their largest radius, in document coordinates.
|
||||
fn corners_at_limit(&self) -> BezPath {
|
||||
let shown = &self.widgets.widgets;
|
||||
let mut radii = self.radii.clone();
|
||||
for w in shown {
|
||||
if let Some(r) = radii.get_mut(w.corner.index) {
|
||||
*r = self.radius;
|
||||
}
|
||||
}
|
||||
let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= w.corner.max_radius() - 1e-9);
|
||||
let (path, sources) = cut_corners(&self.base, &radii, &self.kinds);
|
||||
let mut out = BezPath::new();
|
||||
let Some(sp) = path.subpaths.first() else { return out };
|
||||
// A corner's cut is the segment between its two anchors (the last one, for the top-left).
|
||||
for i in 0..sp.segment_count() {
|
||||
let (a, b) = (corner_of.get(i), corner_of.get(i + 1).or(corner_of.first()));
|
||||
if a == b && a.is_some_and(|k| w.shown.get(*k) == Some(&true)) {
|
||||
let c = sp.segment(i);
|
||||
out.move_to(c.p0);
|
||||
out.curve_to(c.p1, c.p2, c.p3);
|
||||
for (sp, from) in path.transformed(self.widgets.xf).subpaths.iter().zip(&sources) {
|
||||
// A corner's cut is the segment between its two anchors (the last one, for a closed
|
||||
// subpath's first corner).
|
||||
for i in 0..sp.segment_count() {
|
||||
let (a, b) = (from.get(i), from.get(i + 1).or(from.first()));
|
||||
if a == b && a.is_some_and(full) {
|
||||
let c = sp.segment(i);
|
||||
out.move_to(c.p0);
|
||||
out.curve_to(c.p1, c.p2, c.p3);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -205,22 +249,21 @@ impl CornerDrag {
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
if self.began {
|
||||
vec![Action::Commit]
|
||||
} else if self.alt {
|
||||
let kind = self.widgets.kinds[self.corner].next();
|
||||
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(kind)))]
|
||||
} else if let Some(w) = self.widgets.widgets.get(self.widget).filter(|_| self.alt) {
|
||||
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(w.kind.next())))]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// The radius readout next to the pointer; at the largest radius, the edited corners in red.
|
||||
/// The radius readout next to the pointer; at the largest radius, the corners there in red.
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if !self.began {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) }];
|
||||
if self.at_limit {
|
||||
out.push(Overlay::Path { path: self.edited_corners(), color: crate::builder::HIGHLIGHT_RED, width: 2.0, dashed: false });
|
||||
out.push(Overlay::Path { path: self.corners_at_limit(), color: crate::builder::HIGHLIGHT_RED, width: 2.0, dashed: false });
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -240,24 +283,38 @@ mod tests {
|
||||
|
||||
/// A live `w` × `h` rectangle with corner radius `r`.
|
||||
fn live_shape(w: f64, h: f64, r: f64, xf: Affine) -> (Document, NodeId) {
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
|
||||
doc_with(live.to_path(), Some(live))
|
||||
}
|
||||
|
||||
/// A document holding one path with its live shape.
|
||||
fn doc_with(path: PathData, live: Option<LiveShape>) -> (Document, NodeId) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
|
||||
let mut n = Node::path(id, live.to_path(), Appearance::default_art());
|
||||
let mut n = Node::path(id, path, Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(live);
|
||||
*slot = live;
|
||||
}
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// A five-pointed star at (200, 200) of radii 60 and 30 (#511): a plain path.
|
||||
fn star() -> (Document, NodeId) {
|
||||
doc_with(vectorcraft_geom::shapes::star(Point::new(200.0, 200.0), 60.0, 30.0, 5, 0.0), None)
|
||||
}
|
||||
|
||||
fn selected(id: NodeId) -> Selection {
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
s
|
||||
}
|
||||
|
||||
fn pts(w: &CornerWidgets) -> Vec<Point> {
|
||||
w.visible().collect()
|
||||
}
|
||||
|
||||
fn radius(a: &Action) -> f64 {
|
||||
let Action::Preview(c, v) = a else { panic!("not a preview: {a:?}") };
|
||||
assert_eq!(c, "object.setLiveShape");
|
||||
@@ -267,17 +324,19 @@ mod tests {
|
||||
#[test]
|
||||
fn widgets_sit_inside_each_corner() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap();
|
||||
assert_eq!(w.points, [Point::new(110.0, 110.0), Point::new(190.0, 110.0), Point::new(190.0, 190.0), Point::new(110.0, 190.0)]);
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap();
|
||||
assert_eq!(pts(&w), [Point::new(110.0, 110.0), Point::new(190.0, 110.0), Point::new(190.0, 190.0), Point::new(110.0, 190.0)]);
|
||||
// A larger radius moves them to the arc centres; zooming in keeps a 10 px minimum.
|
||||
let (d, id) = live_rect(20.0, Affine::translate((100.0, 100.0)));
|
||||
assert_eq!(CornerWidgets::of(&d, &selected(id), 1.0).unwrap().points[0], Point::new(120.0, 120.0));
|
||||
assert_eq!(CornerWidgets::of(&d, &selected(id), 4.0).unwrap().points[2], Point::new(180.0, 180.0));
|
||||
// Hidden when the shape is tiny on screen, for plain paths and without a single selection.
|
||||
assert!(CornerWidgets::of(&d, &selected(id), 0.2).is_none());
|
||||
assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap())[0], Point::new(120.0, 120.0));
|
||||
assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 4.0, false).unwrap())[2], Point::new(180.0, 180.0));
|
||||
// Hidden when the shape is tiny on screen and without a single selection; a plain path
|
||||
// shows them with Direct Selection only.
|
||||
assert!(CornerWidgets::of(&d, &selected(id), 0.2, true).is_none());
|
||||
let (d, id) = doc_with_rect();
|
||||
assert!(CornerWidgets::of(&d, &selected(id), 1.0).is_none());
|
||||
assert!(CornerWidgets::of(&d, &Selection::default(), 1.0).is_none());
|
||||
assert!(CornerWidgets::of(&d, &selected(id), 1.0, false).is_none());
|
||||
assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 1.0, true).unwrap())[0], Point::new(110.0, 110.0));
|
||||
assert!(CornerWidgets::of(&d, &Selection::default(), 1.0, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -307,9 +366,9 @@ mod tests {
|
||||
fn widgets_of_a_non_square_rectangle_follow_the_drawn_corners() {
|
||||
let (d, id) = live_shape(200.0, 80.0, 60.0, Affine::translate((100.0, 100.0)));
|
||||
let s = selected(id);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
|
||||
let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap();
|
||||
let centres = [Point::new(140.0, 140.0), Point::new(260.0, 140.0), Point::new(260.0, 140.0), Point::new(140.0, 140.0)];
|
||||
assert_eq!(w.points, centres);
|
||||
assert_eq!(pts(&w), centres);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let red = |t: &dyn crate::Tool| {
|
||||
@@ -339,8 +398,8 @@ mod tests {
|
||||
// 50 × 80 with 10 pt corners stretched 4 × 1: drawn 200 × 80, a mean radius of 20.
|
||||
let (d, id) = live_shape(50.0, 80.0, 10.0, Affine::translate((100.0, 100.0)) * Affine::scale_non_uniform(4.0, 1.0));
|
||||
let s = selected(id);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
|
||||
assert_eq!(w.points[0], Point::new(120.0, 120.0));
|
||||
let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap();
|
||||
assert_eq!(pts(&w)[0], Point::new(120.0, 120.0));
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = crate::create("directSelection");
|
||||
@@ -357,8 +416,8 @@ mod tests {
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
|
||||
assert!(w.points[0].distance(Point::new(190.0, 110.0)) < 1e-9);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap();
|
||||
assert!(pts(&w)[0].distance(Point::new(190.0, 110.0)) < 1e-9);
|
||||
let mut t = crate::create("directSelection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 120.0));
|
||||
@@ -377,10 +436,10 @@ mod tests {
|
||||
s.anchors.insert(id, [(0, 2)].into());
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
|
||||
assert_eq!(w.shown, [false, false, true, false]);
|
||||
assert_eq!(w.visible().collect::<Vec<_>>(), [Point::new(190.0, 190.0)]);
|
||||
assert!(!over_widget(&cx, Point::new(110.0, 110.0)), "the other corners hide theirs");
|
||||
let w = CornerWidgets::of(&d, &s, 1.0, true).unwrap();
|
||||
assert_eq!(w.corners(), [2]);
|
||||
assert_eq!(pts(&w), [Point::new(190.0, 190.0)]);
|
||||
assert!(!over_widget(&cx, Point::new(110.0, 110.0), true), "the other corners hide theirs");
|
||||
let mut t = crate::create("directSelection");
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 191.0)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 175.0, 176.0));
|
||||
@@ -423,20 +482,20 @@ mod tests {
|
||||
#[test]
|
||||
fn corners_wider_than_the_preference_hide_their_widgets() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap();
|
||||
assert_eq!(w.within_angle(177.0).map(|w| w.shown), Some([true; 4]));
|
||||
assert_eq!(w.within_angle(90.0).map(|w| w.shown), Some([true; 4]));
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap();
|
||||
assert_eq!(w.clone().within_angle(177.0).map(|w| w.corners()), Some(vec![0, 1, 2, 3]));
|
||||
assert_eq!(w.clone().within_angle(90.0).map(|w| w.corners()), Some(vec![0, 1, 2, 3]));
|
||||
assert!(w.within_angle(89.0).is_none());
|
||||
// Sheared by 30°: 60° at the top-left and bottom-right corners, 120° at the others.
|
||||
let shear = Affine::translate((100.0, 100.0)) * Affine::new([1.0, 0.0, 30f64.to_radians().tan(), 1.0, 0.0, 0.0]);
|
||||
let (d, id) = live_rect(0.0, shear);
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap();
|
||||
assert_eq!(w.within_angle(100.0).map(|w| w.shown), Some([true, false, true, false]));
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap();
|
||||
assert_eq!(w.clone().within_angle(100.0).map(|w| w.corners()), Some(vec![0, 2]));
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let c = ToolContext { corner_widget_max_angle: 100.0, ..cx(&d, &s, &p) };
|
||||
assert!(over_widget(&c, w.points[0]));
|
||||
assert!(!over_widget(&c, w.points[1]), "the 120° corner's widget is hidden");
|
||||
assert!(over_widget(&c, pts(&w)[0], false));
|
||||
assert!(!over_widget(&c, pts(&w)[1], false), "the 120° corner's widget is hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -445,14 +504,113 @@ mod tests {
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let mut c = cx(&d, &s, &p);
|
||||
assert!(over_widget(&c, Point::new(110.0, 110.0)));
|
||||
assert!(!over_widget(&c, Point::new(150.0, 150.0)));
|
||||
assert!(over_widget(&c, Point::new(110.0, 110.0), false));
|
||||
assert!(!over_widget(&c, Point::new(150.0, 150.0), false));
|
||||
c.corner_widgets = false;
|
||||
assert!(!over_widget(&c, Point::new(110.0, 110.0)));
|
||||
assert!(!over_widget(&c, Point::new(110.0, 110.0), true));
|
||||
// With the widgets hidden, a drag from the same spot moves the object.
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 110.0, 110.0));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 110.0));
|
||||
assert_eq!(a[0], Action::Begin("Move".into()));
|
||||
}
|
||||
|
||||
/// #511: Direct Selection shows a widget in each of a star's ten corners (the Selection tool
|
||||
/// none: a star isn't a live shape); dragging a tip's inward rounds them all as one step.
|
||||
#[test]
|
||||
fn a_star_shows_widgets_in_every_corner_with_direct_selection() {
|
||||
let (d, id) = star();
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
assert!(CornerWidgets::for_tool(&cx, false).is_none());
|
||||
let w = CornerWidgets::for_tool(&cx, true).expect("widgets on a plain path");
|
||||
assert_eq!(w.corners(), (0..10).collect::<Vec<_>>());
|
||||
// The top tip's widget sits 10 √2 px down its bisector.
|
||||
let tip = pts(&w)[0];
|
||||
assert!(tip.distance(Point::new(200.0, 140.0 + 10.0 * 2f64.sqrt())) < 1e-9, "{tip:?}");
|
||||
let mut t = crate::create("directSelection");
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tip.x, tip.y + 10.0));
|
||||
assert_eq!(a[0], Action::Begin("Corner Radius".into()));
|
||||
// The widget follows the arc's centre: r / sin(angle / 2) along the bisector.
|
||||
let c = vectorcraft_geom::corners::path_corners(&star_path(&d, id))[0];
|
||||
let want = 10.0 * (c.angle_deg() / 2.0).to_radians().sin();
|
||||
assert!((radius(&a[1]) - want).abs() < 1e-9, "{a:?} want {want}");
|
||||
assert!(matches!(&a[1], Action::Preview(_, v) if v["id"] == id.0 && v.get("corners").is_none()), "every corner: {a:?}");
|
||||
// The Selection tool drags the star instead.
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y));
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tip.x, tip.y + 10.0))[0], Action::Begin("Move".into()));
|
||||
}
|
||||
|
||||
fn star_path(d: &Document, id: NodeId) -> PathData {
|
||||
let Some(NodeKind::Path { path, .. }) = d.node(id).map(|n| &n.kind) else { panic!("not a path") };
|
||||
path.clone()
|
||||
}
|
||||
|
||||
/// #511: a Direct-Selected anchor of the star shows its corner's widget alone, and the
|
||||
/// preference hides the star's wide inner corners (about 125°) below their angle.
|
||||
#[test]
|
||||
fn star_corners_follow_the_anchors_and_the_angle_preference() {
|
||||
let (d, id) = star();
|
||||
let mut s = selected(id);
|
||||
s.anchors.insert(id, [(0, 3)].into());
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let w = CornerWidgets::for_tool(&c, true).unwrap();
|
||||
assert_eq!(w.corners(), [3]);
|
||||
let mut t = crate::create("directSelection");
|
||||
let at = pts(&w)[0];
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 20.0));
|
||||
assert!(matches!(&a[1], Action::Preview(_, v) if v["corners"] == json!([3])), "{a:?}");
|
||||
// Corners wider than 90° hide: the five tips keep theirs.
|
||||
let whole = selected(id);
|
||||
let c = ToolContext { corner_widget_max_angle: 90.0, ..cx(&d, &whole, &p) };
|
||||
let w = CornerWidgets::for_tool(&c, true).unwrap();
|
||||
assert_eq!(w.corners(), [0, 2, 4, 6, 8]);
|
||||
assert_eq!(w.command("radius", json!(4.0))["corners"], json!([0, 2, 4, 6, 8]));
|
||||
}
|
||||
|
||||
/// A live polygon shows its widgets to the Selection tool too; past every corner's limit the
|
||||
/// cuts outline in red.
|
||||
#[test]
|
||||
fn a_live_polygon_rounds_with_either_tool() {
|
||||
let live = LiveShape::Polygon { radius: 80.0, sides: 6, xf: Affine::translate((200.0, 200.0)), radii: vec![], kinds: vec![] };
|
||||
let (d, id) = doc_with(live.to_path(), Some(live));
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
for any_path in [false, true] {
|
||||
assert_eq!(CornerWidgets::for_tool(&cx, any_path).unwrap().corners().len(), 6);
|
||||
}
|
||||
let tip = pts(&CornerWidgets::for_tool(&cx, false).unwrap())[0];
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 300.0));
|
||||
// A hexagon's 120° corners stop where their cuts meet: 40 · tan 60°.
|
||||
assert!((radius(&a[1]) - 40.0 * 3f64.sqrt()).abs() < 1e-9, "{a:?}");
|
||||
let red = t
|
||||
.overlays(&cx)
|
||||
.into_iter()
|
||||
.any(|o| matches!(o, Overlay::Path { color, path, .. } if color == crate::builder::HIGHLIGHT_RED && path.elements().len() == 12));
|
||||
assert!(red, "six arcs at the limit");
|
||||
}
|
||||
|
||||
/// A pen path's middle corner shows a widget; its ends and curved corners don't.
|
||||
#[test]
|
||||
fn a_pen_path_shows_widgets_on_its_corners_only() {
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(
|
||||
&[Point::new(100.0, 100.0), Point::new(300.0, 100.0), Point::new(300.0, 300.0), Point::new(100.0, 300.0)],
|
||||
false,
|
||||
);
|
||||
sp.anchors[2].h_out = Point::new(250.0, 350.0);
|
||||
let (d, id) = doc_with(PathData::single(sp), None);
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let w = CornerWidgets::for_tool(&cx(&d, &s, &p), true).unwrap();
|
||||
assert_eq!(w.corners(), [1]);
|
||||
assert_eq!(pts(&w), [Point::new(290.0, 110.0)]);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! steps), drag a direction handle to
|
||||
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
|
||||
//! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are
|
||||
//! deselected, the others selected), drag a live rectangle's corner widget to round its corners
|
||||
//! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners
|
||||
//! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens
|
||||
//! the Corners dialog).
|
||||
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group;
|
||||
@@ -317,13 +317,13 @@ impl Tool for DirectSelectionTool {
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [target.0]}))]
|
||||
}
|
||||
}
|
||||
(PointerKind::DoubleClick, _) if !self.group => corners::double_click(cx, p).into_iter().collect(),
|
||||
(PointerKind::DoubleClick, _) if !self.group => corners::double_click(cx, p, true).into_iter().collect(),
|
||||
(PointerKind::Move, State::Idle) => {
|
||||
self.hover = if self.group || !cx.highlight_anchors { None } else { hovered_anchor(cx, p) };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Down, _) => {
|
||||
if let Some(c) = CornerDrag::hit(cx, ev) {
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, true) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
}
|
||||
@@ -597,7 +597,7 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p)) {
|
||||
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p, true)) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p)) {
|
||||
|
||||
@@ -234,7 +234,7 @@ pub struct ToolContext<'a> {
|
||||
/// points (a transform's reference point) land on anchors and ruler guides within
|
||||
/// [`Self::snap_tolerance`].
|
||||
pub snap_to_point: bool,
|
||||
/// View → Show Corner Widget: live rectangles show draggable Live Corners widgets.
|
||||
/// View → Show Corner Widget: paths show draggable Live Corners widgets in their corners.
|
||||
pub corner_widgets: bool,
|
||||
/// Which paint proxy is in front (true = Fill): the one the Gradient tool edits.
|
||||
pub fill_active: bool,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! the selected ones are deselected, the others selected), move (Alt copies, Shift constrains; Smart
|
||||
//! Guides, or with them off View › Snap to Point, snap it),
|
||||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
||||
//! drag a live rectangle's corner widget to round its corners (Alt-click cycles their kind,
|
||||
//! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind,
|
||||
//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To
|
||||
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
|
||||
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
|
||||
@@ -169,7 +169,7 @@ impl Tool for SelectionTool {
|
||||
match (ev.kind, self.state.clone()) {
|
||||
(PointerKind::DoubleClick, _) => {
|
||||
self.state = State::Idle;
|
||||
if let Some(a) = corners::double_click(cx, p).or_else(|| typewidget::double_click(cx, p)) {
|
||||
if let Some(a) = corners::double_click(cx, p, false).or_else(|| typewidget::double_click(cx, p)) {
|
||||
return vec![a];
|
||||
}
|
||||
if crate::rulerguide::guide_at(cx, p).is_some() {
|
||||
@@ -191,7 +191,7 @@ impl Tool for SelectionTool {
|
||||
(PointerKind::Down, _) => {
|
||||
// 1. Live Corners widgets and type on a path's brackets, then the bounding-box
|
||||
// handles of the current selection.
|
||||
if let Some(c) = CornerDrag::hit(cx, ev) {
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, false) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
}
|
||||
@@ -381,7 +381,7 @@ impl Tool for SelectionTool {
|
||||
if self.guide.busy() {
|
||||
return self.guide.cursor(cx, p).unwrap_or_default();
|
||||
}
|
||||
if over_widget(cx, p) {
|
||||
if over_widget(cx, p, false) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if over_bracket(cx, p) {
|
||||
|
||||
@@ -1419,11 +1419,16 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
anchor_square(p, xf.to_screen(a.p), color, false, anchor(direct));
|
||||
}
|
||||
}
|
||||
// Live Corners widgets (Selection / Direct Selection on a single live rectangle).
|
||||
// Live Corners widgets (Selection on a live rectangle or polygon, Direct Selection on any path).
|
||||
if matches!(tool, "selection" | "directSelection")
|
||||
&& app.ui.view.corner_widgets
|
||||
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, xf.zoom)
|
||||
.and_then(|w| w.within_angle(app.session.prefs.hide_corner_widget_above))
|
||||
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::showing(
|
||||
&st.doc,
|
||||
&st.selection,
|
||||
xf.zoom,
|
||||
tool == "directSelection",
|
||||
app.ui.view.corner_widgets,
|
||||
app.session.prefs.hide_corner_widget_above,
|
||||
)
|
||||
{
|
||||
let color = c32(st.doc.layer_color(w.id));
|
||||
for sp in w.visible().map(|q| xf.to_screen(q)) {
|
||||
@@ -2249,6 +2254,25 @@ mod tests {
|
||||
assert_eq!(widgets(&mut app), 0);
|
||||
}
|
||||
|
||||
/// #511: a star shows a widget in each of its ten corners with Direct Selection (none with
|
||||
/// the Selection tool: it isn't a live shape); View → Hide Corner Widget hides them.
|
||||
#[test]
|
||||
fn a_star_shows_corner_widgets_with_direct_selection() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.star", &json!({"cx": 200, "cy": 150, "radius1": 80, "radius2": 40})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let widgets = |app: &mut VectorcraftApp| {
|
||||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count()
|
||||
};
|
||||
app.select_tool("selection");
|
||||
assert_eq!(widgets(&mut app), 0);
|
||||
app.select_tool("directSelection");
|
||||
assert_eq!(widgets(&mut app), 10);
|
||||
app.run("view.cornerWidget", json!({})).unwrap();
|
||||
assert_eq!(widgets(&mut app), 0);
|
||||
}
|
||||
|
||||
/// The type widget beside selected type's bounding box: hollow (white) on point type, filled
|
||||
/// in the layer colour on area type.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Checks the system clipboard for Paste off the UI thread. `has()` can block for seconds when the
|
||||
//! clipboard owner never answers (an unresponsive X11 client), so it must not run on the thread that
|
||||
//! draws the window: a worker owns a second clipboard handle, sends its verdict when it changes, and
|
||||
//! the UI only reads the latest one. Without a worker the check stays in line, as before (the web,
|
||||
//! and hosts that don't install a factory).
|
||||
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
|
||||
|
||||
use crate::sysclip::ClipboardProbeFactory;
|
||||
|
||||
/// How often the worker looks at the clipboard while the UI wants to know.
|
||||
const PROBE_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
/// How long dropping the probe waits for the worker to let go of its clipboard handle.
|
||||
const STOP_WAIT: Duration = Duration::from_millis(250);
|
||||
|
||||
/// The background clipboard check ([`crate::VectorcraftApp`]'s `clipboard_probe`).
|
||||
pub(crate) struct Probe {
|
||||
/// Tells the worker whether Paste depends on the system clipboard; dropped to stop it.
|
||||
wants: Option<Sender<bool>>,
|
||||
wanted: bool,
|
||||
verdicts: Receiver<bool>,
|
||||
/// The worker's last verdict.
|
||||
last: bool,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
/// Start the worker. `None` on wasm, or when the thread can't be spawned: the caller then falls
|
||||
/// back to the in-line check.
|
||||
pub(crate) fn start(make: ClipboardProbeFactory, ctx: egui::Context) -> Option<Self> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return None;
|
||||
}
|
||||
let (wants, wanted_rx) = std::sync::mpsc::channel();
|
||||
let (tx, verdicts) = std::sync::mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("vectorcraft-clip".into())
|
||||
.spawn(move || {
|
||||
let mut clipboard = make();
|
||||
let (mut wanted, mut sent) = (false, None);
|
||||
loop {
|
||||
if wanted {
|
||||
// This thread may wait here for a slow owner; the UI's never does.
|
||||
let verdict = clipboard.has(&PASTE_ORDER);
|
||||
if sent != Some(verdict) {
|
||||
if tx.send(verdict).is_err() {
|
||||
break;
|
||||
}
|
||||
sent = Some(verdict);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
// Sleep until the next look (no looks while unwanted) or the UI's next word.
|
||||
let next =
|
||||
if wanted { wanted_rx.recv_timeout(PROBE_INTERVAL) } else { wanted_rx.recv().map_err(|_| RecvTimeoutError::Disconnected) };
|
||||
match next {
|
||||
Ok(w) => wanted = w,
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
// The handle goes before the app stops waiting (`Drop`): on X11 the last one alive
|
||||
// hands what the app copied to the clipboard manager, which must finish before exit.
|
||||
drop(clipboard);
|
||||
drop(tx);
|
||||
})
|
||||
.ok()?;
|
||||
Some(Self { wants: Some(wants), wanted: false, verdicts, last: false })
|
||||
}
|
||||
|
||||
/// Paste can take from the system clipboard: `wanted` (nothing copied in the app, a document
|
||||
/// open) and the worker's latest verdict says so. Never blocks. `None` once the worker is gone.
|
||||
pub(crate) fn pasteable(&mut self, wanted: bool) -> Option<bool> {
|
||||
if wanted != self.wanted {
|
||||
self.wants.as_ref()?.send(wanted).ok()?;
|
||||
self.wanted = wanted;
|
||||
}
|
||||
loop {
|
||||
match self.verdicts.try_recv() {
|
||||
Ok(verdict) => self.last = verdict,
|
||||
Err(TryRecvError::Empty) => return Some(wanted && self.last),
|
||||
Err(TryRecvError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Probe {
|
||||
/// Wake the worker to stop and wait (a moment at most: it may be stuck on an owner that never
|
||||
/// answers) until it has let go of its clipboard handle.
|
||||
fn drop(&mut self) {
|
||||
self.wants = None;
|
||||
let end = Instant::now() + STOP_WAIT;
|
||||
while self.verdicts.recv_timeout(end.saturating_duration_since(Instant::now())).is_ok() {}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
//! Corners: double-clicking a Live Corners widget (or `ui.corners`) sets the corner kind and
|
||||
//! radius of a live rectangle's corners: those whose widgets show (the Direct-Selected ones, else
|
||||
//! all four). OK runs `object.setLiveShape` for them, one undo step.
|
||||
//! radius of a path's corners: those whose widgets show (the Direct-Selected ones, else every
|
||||
//! corner). OK runs `object.setLiveShape` for them, one undo step.
|
||||
//!
|
||||
//! Fields: `id`, `corners` (indices 0–3: top-left, top-right, bottom-right, bottom-left), `kind`
|
||||
//! ("round", "invertedRound" or "chamfer") and `radius` (pt). While the corners differ in kind
|
||||
//! or radius, that field is absent and OK leaves it as it is.
|
||||
//! Fields: `id`, `corners` (anchor indices of the path with its corners uncut: a rectangle's 0–3
|
||||
//! from the top-left clockwise), `kind` ("round", "invertedRound" or "chamfer") and `radius`
|
||||
//! (pt). While the corners differ in kind or radius, that field is absent and OK leaves it as it
|
||||
//! is.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::doc::{NodeId, NodeKind};
|
||||
use vectorcraft_engine::doc::{LiveCorners, NodeId};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
|
||||
use super::swatch_options::{grid, label};
|
||||
@@ -20,7 +23,7 @@ pub const KIND: &str = vectorcraft_tools::corners::DIALOG;
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Corners").into(), body, confirm, min_width: 280.0, ..DialogSpec::FORM };
|
||||
|
||||
/// Open Corners for `{id?, corners?}`: the live rectangle `id` (default: the selected one) and its
|
||||
/// Open Corners for `{id?, corners?}`: the path `id` (default: the selected one) and its
|
||||
/// `corners` (default: the selected corners), filled in with their kind and radius.
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
@@ -28,22 +31,21 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
Some(id) => NodeId(id),
|
||||
None => match st.selection.objects[..] {
|
||||
[id] => id,
|
||||
_ => return Err("select one live rectangle, or give its `id`".into()),
|
||||
_ => return Err("select one path, or give its `id`".into()),
|
||||
},
|
||||
};
|
||||
let Some(NodeKind::Path { live: Some(live @ vectorcraft_engine::doc::LiveShape::Rectangle { .. }), .. }) = st.doc.node(id).map(|n| &n.kind)
|
||||
else {
|
||||
return Err("Corners edits a live rectangle".into());
|
||||
let Some(live) = st.doc.node(id).and_then(LiveCorners::of) else {
|
||||
return Err("Corners edits the corners of a path".into());
|
||||
};
|
||||
let corners = match p.get("corners").and_then(Value::as_array) {
|
||||
Some(a) => std::array::from_fn(|k| a.iter().any(|v| v.as_u64() == Some(k as u64))),
|
||||
None => live.picked_corners(st.selection.partial(id)),
|
||||
let corners: BTreeSet<usize> = match p.get("corners").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_u64).filter_map(|k| usize::try_from(k).ok()).filter(|k| live.corner(*k).is_some()).collect(),
|
||||
None => live.picked(st.selection.partial(id)),
|
||||
};
|
||||
if !corners.contains(&true) {
|
||||
return Err("give corners 0–3".into());
|
||||
if corners.is_empty() {
|
||||
return Err("give corners of the path: anchors between two straight sides".into());
|
||||
}
|
||||
let (radius, kind) = live.corner_style(corners);
|
||||
let mut fields = json!({"id": id.0, "corners": (0..4).filter(|k| corners[*k]).collect::<Vec<_>>()});
|
||||
let (radius, kind) = live.style(&corners);
|
||||
let mut fields = json!({"id": id.0, "corners": corners});
|
||||
if let Some(k) = kind {
|
||||
fields["kind"] = json!(k);
|
||||
}
|
||||
@@ -94,7 +96,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::doc::LiveShape;
|
||||
use vectorcraft_engine::doc::{LiveShape, NodeKind};
|
||||
use vectorcraft_tools::corners::CornerWidgets;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
@@ -121,7 +123,7 @@ mod tests {
|
||||
/// The radius the Properties panel's Corner Radius field shows.
|
||||
fn panel_radius(app: &VectorcraftApp, id: u64) -> Option<f64> {
|
||||
let n = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().clone();
|
||||
crate::panels::corner_radius(app, &n, &live(app, id))
|
||||
crate::panels::corner_radius(app, &n)
|
||||
}
|
||||
|
||||
fn shown(app: &mut VectorcraftApp) -> Vec<String> {
|
||||
@@ -151,7 +153,7 @@ mod tests {
|
||||
app.select_tool("directSelection");
|
||||
let view = app.view_info();
|
||||
let st = app.session.active().unwrap();
|
||||
let at: Vec<_> = CornerWidgets::of(&st.doc, &st.selection, view.zoom).unwrap().visible().collect();
|
||||
let at: Vec<_> = CornerWidgets::of(&st.doc, &st.selection, view.zoom, true).unwrap().visible().collect();
|
||||
assert_eq!(at.len(), 1);
|
||||
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::DoubleClick, at[0].x, at[0].y), view);
|
||||
let d = app.ui.dialog.clone().expect("Corners opened");
|
||||
@@ -192,8 +194,25 @@ mod tests {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("radius".into(), json!("3 pt"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(corners(&app, id), ([3.0; 4], before.1));
|
||||
// Only live rectangles, and only real corners.
|
||||
// Only paths, and only real corners.
|
||||
assert!(app.run("ui.corners", json!({"id": 9999})).is_err());
|
||||
assert!(app.run("ui.corners", json!({"corners": [7]})).is_err());
|
||||
let e = app.run("shape.ellipse", json!({"x": 0, "y": 0, "width": 50, "height": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(app.run("ui.corners", json!({"id": e})).is_err());
|
||||
}
|
||||
|
||||
/// #511: a star's corners open in Corners too; OK rounds them.
|
||||
#[test]
|
||||
fn corners_edits_a_star() {
|
||||
let (mut app, _) = app();
|
||||
let id = app.run("shape.star", json!({"cx": 200, "cy": 200, "radius1": 60, "radius2": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("ui.corners", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!((d.fields.get("corners"), d.f64("radius", -1.0)), (Some(&json!((0..10).collect::<Vec<_>>())), 0.0));
|
||||
d.fields.insert("radius".into(), json!(5));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let n = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().clone();
|
||||
assert!(matches!(&n.kind, NodeKind::Path { live: Some(LiveShape::Path { .. }), .. }));
|
||||
assert_eq!(crate::panels::corner_radius(&app, &n), Some(5.0));
|
||||
}
|
||||
}
|
||||
@@ -3072,7 +3072,7 @@ axis Both Ambos
|
||||
@msg Convert to Artboards: selection has no area Convertir en mesas de trabajo: la selección no tiene área
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Convertir en forma: no hay rectángulos ni elipses seleccionados
|
||||
@msg Copied the CSS of {rules} object(s) Se copió el CSS de {rules} objeto(s)
|
||||
@msg Corners edits a live rectangle Esquinas edita un rectángulo interactivo
|
||||
@msg Corners edits the corners of a path Esquinas edita las esquinas de un trazado
|
||||
@msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e}
|
||||
@msg Couldn't open {name}: {e} No se pudo abrir {name}: {e}
|
||||
@msg Couldn't place {name}: the document it was dropped on was closed No se pudo colocar {name}: se cerró el documento en el que se soltó
|
||||
@@ -3238,7 +3238,7 @@ axis Both Ambos
|
||||
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versión de archivo {0} es más reciente de lo que admite este VectorCraft ({VERSION})
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` el punto de forma libre {i} necesita `at` [x, y] y un `color` válido
|
||||
@msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos)
|
||||
@msg give corners 0–3 indica esquinas de 0 a 3
|
||||
@msg give corners of the path: anchors between two straight sides indica esquinas del trazado: puntos de ancla entre dos lados rectos
|
||||
@msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura
|
||||
@msg guides are locked las guías están bloqueadas
|
||||
@msg image '{key}' has no pixels and was left out la imagen '{key}' no tiene píxeles y se omitió
|
||||
@@ -3422,7 +3422,7 @@ axis Both Ambos
|
||||
@msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear
|
||||
@msg select at least two objects selecciona al menos dos objetos
|
||||
@msg select objects, not layers selecciona objetos, no capas
|
||||
@msg select one live rectangle, or give its `id` selecciona un rectángulo interactivo o indica su `id`
|
||||
@msg select one path, or give its `id` selecciona un trazado o indica su `id`
|
||||
@msg select paths to join selecciona los trazados que quieres unir
|
||||
@msg select the art to repeat selecciona la ilustración que quieres repetir
|
||||
@msg select the artwork to warp selecciona la ilustración que quieres deformar
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -17,6 +17,7 @@ pub mod background;
|
||||
mod brand;
|
||||
pub mod canvas;
|
||||
pub mod chrome;
|
||||
mod clipboard_probe;
|
||||
pub mod community;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
@@ -129,6 +130,8 @@ mod tests_sysclip;
|
||||
#[cfg(test)]
|
||||
mod tests_sysclip_emf;
|
||||
#[cfg(test)]
|
||||
mod tests_sysclip_probe;
|
||||
#[cfg(test)]
|
||||
mod tests_transparencygrid;
|
||||
#[cfg(test)]
|
||||
mod tests_widthtool;
|
||||
@@ -142,7 +145,7 @@ use vectorcraft_engine::{Session, ViewInfo};
|
||||
|
||||
pub use control::{ControlRequest, ControlResponse};
|
||||
pub use state::{UiState, View};
|
||||
pub use sysclip::SystemClipboard;
|
||||
pub use sysclip::{ClipboardProbeFactory, SystemClipboard};
|
||||
|
||||
/// What a file dialog shows: a suggested file name (save dialogs), the folder to start in and the
|
||||
/// file-type filters.
|
||||
@@ -205,6 +208,10 @@ pub struct Services {
|
||||
/// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through
|
||||
/// egui and `clipboard_read`).
|
||||
pub system_clipboard: Option<Box<dyn SystemClipboard>>,
|
||||
/// A second system-clipboard handle, for checking whether Paste has something to take on a
|
||||
/// background thread (Linux: an X11 clipboard owner that never answers would freeze the UI).
|
||||
/// Taken on the first frame. Without it the check runs on the UI thread.
|
||||
pub clipboard_probe: Option<ClipboardProbeFactory>,
|
||||
/// File → Show in Folder: select a file in the system file manager (desktop).
|
||||
pub reveal: Option<RevealFn>,
|
||||
/// Write a file from any thread (desktop): lets Background Save and Export write off the UI
|
||||
@@ -327,8 +334,11 @@ pub struct VectorcraftApp {
|
||||
/// (SVG without a `system_clipboard`) while the internal clipboard is empty. It enables the
|
||||
/// Paste menu items.
|
||||
pub(crate) system_paste: bool,
|
||||
/// When `system_paste` was last checked (app time, s; at most once per frame).
|
||||
/// When `system_paste` was last checked in line (app time, s; at most once per frame).
|
||||
system_paste_at: f64,
|
||||
/// The thread that checks the system clipboard for `system_paste` instead
|
||||
/// ([`Services::clipboard_probe`]).
|
||||
clipboard_probe: Option<clipboard_probe::Probe>,
|
||||
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
|
||||
pub(crate) paste_chord: shortcuts::PasteChord,
|
||||
/// Saves and exports running in the background (Preferences → File Handling).
|
||||
@@ -404,6 +414,7 @@ impl VectorcraftApp {
|
||||
place: Default::default(),
|
||||
system_paste: false,
|
||||
system_paste_at: f64::NEG_INFINITY,
|
||||
clipboard_probe: None,
|
||||
paste_chord: Default::default(),
|
||||
background: Default::default(),
|
||||
recovery: Default::default(),
|
||||
@@ -729,11 +740,20 @@ impl VectorcraftApp {
|
||||
}
|
||||
self.last_time = now;
|
||||
self.sync_views();
|
||||
// Read the system clipboard only when that alone decides whether Paste is enabled, and at
|
||||
// most a few times a second (opening it locks it against other apps on some systems).
|
||||
if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_paste_at)) {
|
||||
// Read the system clipboard only when that alone decides whether Paste is enabled. A
|
||||
// background thread reads it where the host installs one (an unresponsive owner must never
|
||||
// stall the frame loop); otherwise, or once that thread is gone, it is read here, at most a
|
||||
// few times a second (opening it locks it against other apps on some systems).
|
||||
let wanted = self.session.clipboard.is_empty() && self.session.active().is_some();
|
||||
if let Some(make) = self.services.clipboard_probe.take() {
|
||||
self.clipboard_probe = clipboard_probe::Probe::start(make, ctx.clone());
|
||||
}
|
||||
if let Some(pasteable) = self.clipboard_probe.as_mut().and_then(|p| p.pasteable(wanted)) {
|
||||
self.system_paste = pasteable;
|
||||
} else if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_paste_at)) {
|
||||
self.clipboard_probe = None;
|
||||
self.system_paste_at = now;
|
||||
self.system_paste = self.session.clipboard.is_empty() && self.session.active().is_some() && self.system_clipboard_pasteable();
|
||||
self.system_paste = wanted && self.system_clipboard_pasteable();
|
||||
}
|
||||
background::poll(self);
|
||||
if !self.background.jobs.is_empty() {
|
||||
|
||||
@@ -485,7 +485,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"ui.corners",
|
||||
"Corners…",
|
||||
"",
|
||||
"{id?, corners?: [0..3…]} open Corners for live rectangle `id` (default: the selected one) and its corners (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else all four) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape",
|
||||
"{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape",
|
||||
),
|
||||
(
|
||||
"ui.colorGuideLimit",
|
||||
|
||||
@@ -45,7 +45,7 @@ pub mod transparency;
|
||||
use egui::{Rect, Sense, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{LiveShape, Node, StrokeLayer};
|
||||
use vectorcraft_doc::{LiveCorners, Node, StrokeLayer};
|
||||
use vectorcraft_engine::inspect::StrokeMixed;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
@@ -57,11 +57,24 @@ pub fn first_selected(app: &VectorcraftApp) -> Option<Node> {
|
||||
first_node(app).cloned()
|
||||
}
|
||||
|
||||
/// The radius the Live Corners of live rectangle `n` show in the panels: that of the corners the
|
||||
/// panels set (the Direct-Selected ones, else all four), blank when they differ.
|
||||
pub(crate) fn corner_radius(app: &VectorcraftApp, n: &Node, live: &LiveShape) -> Option<f64> {
|
||||
/// The radius the Live Corners of path `n` show in the panels: that of the corners the panels set
|
||||
/// (the Direct-Selected ones, else every corner), blank when they differ.
|
||||
pub(crate) fn corner_radius(app: &VectorcraftApp, n: &Node) -> Option<f64> {
|
||||
let partial = app.session.active().and_then(|d| d.selection.partial(n.id));
|
||||
live.corner_style(live.picked_corners(partial)).0
|
||||
let corners = LiveCorners::of(n)?;
|
||||
corners.style(&corners.picked(partial)).0
|
||||
}
|
||||
|
||||
/// The Corner Radius field of a live rectangle's or polygon's properties: [`corner_radius`],
|
||||
/// which a new value sets on those corners.
|
||||
pub(crate) fn corner_radius_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: &str) {
|
||||
let units = app.session.general_unit();
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Corner Radius:"));
|
||||
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), units, 80.0) {
|
||||
app.run("object.setLiveShape", json!({"radius": r})).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Number of selected objects.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeKind, Unit};
|
||||
use vectorcraft_doc::{LiveShape, NodeKind, Unit};
|
||||
|
||||
use super::{corner_radius, first_selected, pstate, set_pstate};
|
||||
use super::{corner_radius_row, first_selected, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, dim_label, divider, section_header};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
@@ -93,7 +93,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
divider(ui);
|
||||
section_header(ui, tl!("Quick Actions"));
|
||||
let is_group = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Group { .. }));
|
||||
let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(_), .. }));
|
||||
let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(l), .. }) if !matches!(l, LiveShape::Path { .. }));
|
||||
let mut actions: Vec<(&str, &str)> = vec![];
|
||||
if n_sel > 1 {
|
||||
actions.push((tl!("Group"), "object.group"));
|
||||
@@ -335,14 +335,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
&& let NodeKind::Path { live: Some(live), .. } = &n.kind
|
||||
{
|
||||
match live {
|
||||
vectorcraft_doc::LiveShape::Rectangle { .. } => {
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Corner Radius:"));
|
||||
if let Some(r) = widgets::num_field(ui, "radius", corner_radius(app, &n, live), units, 80.0) {
|
||||
app.run("object.setLiveShape", json!({"radius": r})).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"),
|
||||
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Sides:"));
|
||||
@@ -350,6 +343,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.run("object.setLiveShape", json!({"sides": s as u32})).ok();
|
||||
}
|
||||
});
|
||||
corner_radius_row(app, ui, &n, "radius");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::{corner_radius, first_selected, pstate, set_pstate};
|
||||
use super::{corner_radius_row, first_selected, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
@@ -166,20 +166,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.run("object.reflect", json!({"axis": "horizontal", "origin": origin})).ok();
|
||||
}
|
||||
});
|
||||
// Live shape properties.
|
||||
// Live shape properties (a path with live corners is no shape).
|
||||
if let Some(n) = first_selected(app)
|
||||
&& let NodeKind::Path { live: Some(live), .. } = &n.kind
|
||||
&& !matches!(live, vectorcraft_doc::LiveShape::Path { .. })
|
||||
{
|
||||
widgets::divider(ui);
|
||||
match live {
|
||||
vectorcraft_doc::LiveShape::Rectangle { .. } => {
|
||||
widgets::subheader(ui, tl!("Rectangle Properties:"));
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, tl!("Corner Radius:"));
|
||||
if let Some(r) = widgets::num_field(ui, "xfp-radius", corner_radius(app, &n, live), units, 80.0) {
|
||||
app.run("object.setLiveShape", json!({"radius": r})).ok();
|
||||
}
|
||||
});
|
||||
corner_radius_row(app, ui, &n, "xfp-radius");
|
||||
}
|
||||
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
|
||||
widgets::subheader(ui, tl!("Polygon Properties:"));
|
||||
@@ -189,6 +185,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.run("object.setLiveShape", json!({"sides": s.clamp(3.0, 20.0) as u32})).ok();
|
||||
}
|
||||
});
|
||||
corner_radius_row(app, ui, &n, "xfp-radius");
|
||||
}
|
||||
_ => {
|
||||
widgets::subheader(ui, tl!("Shape Properties:"));
|
||||
|
||||
@@ -24,6 +24,11 @@ pub trait SystemClipboard {
|
||||
fn has(&mut self, mimes: &[&'static str]) -> bool;
|
||||
}
|
||||
|
||||
/// Makes a second system-clipboard handle, to check off the UI thread whether Paste has something
|
||||
/// to take ([`Services::clipboard_probe`](crate::Services::clipboard_probe)). It is called on that
|
||||
/// thread and the handle lives and dies there, so the handle needn't be `Send`.
|
||||
pub type ClipboardProbeFactory = Box<dyn FnOnce() -> Box<dyn SystemClipboard> + Send>;
|
||||
|
||||
/// The command (and its params) that loads `f` into the internal clipboard, centred on `center`.
|
||||
pub(crate) fn import_command(f: &Flavour, center: Option<[f64; 2]>) -> (&'static str, Value) {
|
||||
let b64 = || vectorcraft_format::base64_encode(&f.data);
|
||||
|
||||
@@ -66,7 +66,7 @@ pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
app.run(id, p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn copy_rect(app: &mut VectorcraftApp) {
|
||||
pub(super) fn copy_rect(app: &mut VectorcraftApp) {
|
||||
let id = run(app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20}))["id"].clone();
|
||||
run(app, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
run(app, "select.set", json!({"ids": [id]}));
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! The system clipboard is checked for Paste off the UI thread where the host installs a probe
|
||||
//! (Linux): an X11 clipboard owner that never answers used to block the frame loop for seconds, so
|
||||
//! the window stopped drawing and could not even be closed. The in-line check (no probe: the web,
|
||||
//! Windows, macOS) is covered by `tests_clipboard` and `tests_sysclip`.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::clipboard::Flavour;
|
||||
|
||||
use crate::tests_sysclip::{copy_rect, run};
|
||||
use crate::{Services, SystemClipboard, VectorcraftApp, menus};
|
||||
|
||||
/// What the clipboard handles saw: the thread each `has()` ran on, and how many handles are alive.
|
||||
#[derive(Default)]
|
||||
struct Recorder {
|
||||
threads: Mutex<Vec<Option<String>>>,
|
||||
alive: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
fn looks(&self) -> usize {
|
||||
self.threads.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
/// A system clipboard whose `has()` takes `delay` (an owner slow to answer) and answers `verdict`,
|
||||
/// or panics without one (a bug in the platform code).
|
||||
struct Slow {
|
||||
rec: Arc<Recorder>,
|
||||
delay: Duration,
|
||||
verdict: Option<bool>,
|
||||
}
|
||||
|
||||
impl Slow {
|
||||
fn boxed(rec: &Arc<Recorder>, delay: Duration, verdict: Option<bool>) -> Box<dyn SystemClipboard> {
|
||||
rec.alive.fetch_add(1, Ordering::SeqCst);
|
||||
Box::new(Self { rec: Arc::clone(rec), delay, verdict })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Slow {
|
||||
fn drop(&mut self) {
|
||||
self.rec.alive.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemClipboard for Slow {
|
||||
fn write(&mut self, _flavours: &[Flavour]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn holds_ours(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
fn read(&mut self, _mimes: &[&'static str]) -> Option<Flavour> {
|
||||
None
|
||||
}
|
||||
fn has(&mut self, _mimes: &[&'static str]) -> bool {
|
||||
self.rec.threads.lock().unwrap().push(std::thread::current().name().map(str::to_owned));
|
||||
std::thread::sleep(self.delay);
|
||||
self.verdict.expect("the clipboard failed")
|
||||
}
|
||||
}
|
||||
|
||||
/// An app whose two clipboard handles record into `rec`: the UI's says `inline`, the probe's
|
||||
/// `probe`, each after `delay`.
|
||||
fn app(rec: &Arc<Recorder>, delay: Duration, inline: bool, probe: Option<bool>) -> VectorcraftApp {
|
||||
let make = {
|
||||
let rec = Arc::clone(rec);
|
||||
move || Slow::boxed(&rec, delay, probe)
|
||||
};
|
||||
let services =
|
||||
Services { system_clipboard: Some(Slow::boxed(rec, delay, Some(inline))), clipboard_probe: Some(Box::new(make)), ..Default::default() };
|
||||
VectorcraftApp::new(Session::new(), services)
|
||||
}
|
||||
|
||||
/// One headless frame of app logic in the test's one context (a new one would restart the fonts),
|
||||
/// half a second after the last: past the in-line check's throttle, so it would run every frame.
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
thread_local! {
|
||||
static CTX: egui::Context = egui::Context::default();
|
||||
static NOW: std::cell::Cell<f64> = const { std::cell::Cell::new(0.0) };
|
||||
}
|
||||
let time = NOW.with(|t| {
|
||||
t.set(t.get() + 0.5);
|
||||
t.get()
|
||||
});
|
||||
let input = egui::RawInput { time: Some(time), ..Default::default() };
|
||||
CTX.with(|ctx| ctx.run_ui(input, |ui| app.logic(ui.ctx())).textures_delta.clear());
|
||||
}
|
||||
|
||||
/// Frames for `wait`, or until `done` holds; whether it does.
|
||||
fn frames_until(app: &mut VectorcraftApp, wait: Duration, done: impl Fn(&VectorcraftApp) -> bool) -> bool {
|
||||
let end = Instant::now() + wait;
|
||||
loop {
|
||||
frame(app);
|
||||
if done(app) || Instant::now() > end {
|
||||
return done(app);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slow_clipboard_is_read_off_the_ui_thread() {
|
||||
let rec = Arc::new(Recorder::default());
|
||||
let mut app = app(&rec, Duration::from_secs(1), false, Some(true));
|
||||
// The first frames install the UI fonts. Without a document Paste is off whatever the clipboard
|
||||
// holds: nobody looks.
|
||||
assert!(!frames_until(&mut app, Duration::from_millis(100), |_| rec.looks() > 0));
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
frame(&mut app);
|
||||
let t0 = Instant::now();
|
||||
frame(&mut app);
|
||||
assert!(t0.elapsed() < Duration::from_millis(300), "the frame waited on the clipboard: {:?}", t0.elapsed());
|
||||
// The probe's verdict (the UI's own handle would say no) enables Paste once it arrives.
|
||||
assert!(frames_until(&mut app, Duration::from_secs(3), |app| app.system_paste), "the probe's verdict never arrived");
|
||||
assert!(menus::enabled(&app, "edit.paste"));
|
||||
let threads = rec.threads.lock().unwrap().clone();
|
||||
assert!(threads.iter().all(|n| n.as_deref() == Some("vectorcraft-clip")), "checked on: {threads:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_probe_rests_after_a_copy_and_stops_with_the_app() {
|
||||
let rec = Arc::new(Recorder::default());
|
||||
let mut app = app(&rec, Duration::ZERO, false, Some(true));
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
assert!(frames_until(&mut app, Duration::from_secs(2), |app| app.system_paste));
|
||||
// Something copied in the app enables Paste alone: the clipboard isn't read any more.
|
||||
copy_rect(&mut app);
|
||||
frames_until(&mut app, Duration::from_millis(50), |_| false);
|
||||
let looks = rec.looks();
|
||||
frames_until(&mut app, Duration::from_millis(600), |_| false);
|
||||
assert_eq!(rec.looks(), looks);
|
||||
assert!(!app.system_paste && menus::enabled(&app, "edit.paste"));
|
||||
// Quitting waits until the probe's thread has let go of its handle: on X11 the last handle
|
||||
// alive hands what the app copied to the clipboard manager.
|
||||
assert_eq!(rec.alive.load(Ordering::SeqCst), 2);
|
||||
drop(app);
|
||||
assert_eq!(rec.alive.load(Ordering::SeqCst), 0, "the probe's thread kept its clipboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_its_thread_the_check_runs_in_line_again() {
|
||||
let rec = Arc::new(Recorder::default());
|
||||
// The probe's clipboard panics: its thread ends.
|
||||
let mut app = app(&rec, Duration::ZERO, true, None);
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
assert!(frames_until(&mut app, Duration::from_secs(2), |app| app.system_paste), "no fallback");
|
||||
assert!(app.clipboard_probe.is_none());
|
||||
let threads = rec.threads.lock().unwrap().clone();
|
||||
assert_eq!(threads.first().cloned().flatten().as_deref(), Some("vectorcraft-clip"), "{threads:?}");
|
||||
}
|
||||
@@ -291,9 +291,9 @@ Width Point Edit: double-clicking a width point with the Width tool, or `ui.widt
|
||||
value: true}` then confirm (the Delete button) removes the point with `stroke.widthPoint.remove`.
|
||||
|
||||
Corners: double-clicking a Live Corners widget with the Selection or Direct Selection tool, or `ui.corners {id?,
|
||||
corners?}`, opens the `corners` dialog for a live rectangle's corners (the Direct-Selected ones, else all four; fields
|
||||
`id`, `corners`: indices 0–3 clockwise from the top-left, `kind`: round, invertedRound or chamfer, `radius` in points;
|
||||
`kind` or `radius` is absent while the corners differ). `ui.dialog.confirm` runs `object.setLiveShape` with them.
|
||||
corners?}`, opens the `corners` dialog for a path's corners (the Direct-Selected ones, else every corner; fields `id`,
|
||||
`corners`: anchor indices of the path with its corners uncut, a rectangle's 0–3 clockwise from the top-left, `kind`:
|
||||
round, invertedRound or chamfer, `radius` in points; `kind` or `radius` is absent while the corners differ). `ui.dialog.confirm` runs `object.setLiveShape` with them.
|
||||
|
||||
Perspective plane options: double-clicking a plane widget of the perspective grid, or `ui.perspectivePlane {plane}`,
|
||||
opens the `perspectivePlane` dialog (fields `plane`: left, right or ground; `location`: points along the plane's
|
||||
|
||||
@@ -74,6 +74,7 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`.
|
||||
The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature:
|
||||
|
||||
- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. egui's own text paste into fields goes through smithay-clipboard and works either way.
|
||||
- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. A paste itself still reads on the UI thread, so pasting from such an owner waits for it once. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server.
|
||||
- **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EMF/WMF or a bitmap), before the clipboard's other formats (which include the files' paths as text).
|
||||
- **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.36 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or run the app under XWayland, where drops work (`WAYLAND_DISPLAY= vectorcraft`).
|
||||
- **Pens and pen displays under Wayland (#491):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336)): the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, either run the app under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer (`WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse, without pressure: winit 0.30 reads pen pressure on Windows only (Windows Ink, which reaches the Liquify tools' Use Pressure Pen). Other compositors that don't emulate a pointer for tablets behave the same way.
|
||||
|
||||
+28
-14
@@ -402,11 +402,14 @@ their stop opacity and stop where the shading doesn't extend. Text becomes point
|
||||
the file's font (by name; fonts that aren't available are listed in `warnings` and show in the fallback font, and
|
||||
every export that draws that type — PDF, EPS, EMF/WMF, raster images, SVG with outlined or embedded fonts — says in
|
||||
its `warnings` that it wrote the fallback font) — `textAs: "outlines"` keeps the glyph outlines the file draws instead
|
||||
(its embedded fonts, installed or not). Strokes stay live strokes (width, cap, join, miter limit, dash and paint), and
|
||||
(its embedded fonts, installed or not). Lines wrapped in a frame (each wrapped line ending in a space, sharing a left
|
||||
edge, font and leading) come back as one area type object, left aligned or justified, where laying it out breaks the
|
||||
lines where the file does; a stroke written as each glyph's outline stroked is the type object's stroke; glyphs set
|
||||
apart by gaps wider than a space keep their places. Strokes stay live strokes (width, cap, join, miter limit, dash and paint), and
|
||||
an object written as a fill and then a stroke of the same outline is one path with both. Optional content groups (the
|
||||
layers of PDF and PDF-compatible `.ai` files) become layers with their name, visibility (the default configuration's,
|
||||
or a view state that is off), print state and lock, nested as sublayers the way the file's layer order nests them; art
|
||||
that is off comes in as a hidden layer. Art outside them goes to a layer per page (except the opaque white page a `.ai`
|
||||
or a view state that is off), print state and lock, nested as sublayers the way the file's layer order nests them, stacked as the pages mark them
|
||||
(layers with no art come in empty); art that is off comes in as a hidden layer. Art outside them goes to a layer per page (except the opaque white page a `.ai`
|
||||
paints under its layers, which isn't art). `layers: false` gives one layer per page of only what shows:
|
||||
|
||||
```json
|
||||
@@ -1132,21 +1135,32 @@ size. The journal entry of a scaling command records the `strokes` and `corners`
|
||||
|
||||
## Live Corners
|
||||
|
||||
`object.setLiveShape {id?, ids?, radius?, kind?, corners?}` sets the corners of live rectangles (one undo step):
|
||||
`radius` (pt) and `kind` (`round`, `invertedRound` or `chamfer`) go to the `corners` given (0 top-left, 1 top-right,
|
||||
2 bottom-right, 3 bottom-left), else to the corners holding a Direct-Selected anchor (`select.anchors`), else to all
|
||||
four. Each corner keeps its own radius and kind (the shape's `live` in queries has `radii` and, when a corner isn't
|
||||
round, `kinds`); a corner with no radius is one anchor, a cut one two, and Direct-Selected corners stay selected as
|
||||
that changes. Every corner is a circular arc, whatever the rectangle's proportions: a radius past half the shorter
|
||||
side draws at half of it (the same limit for every corner), and a rectangle from a file that kept an uneven scale in its
|
||||
transform (elliptical corners) gets circular ones in document units when its corners are next set. With the Selection or
|
||||
Direct Selection tool, dragging a corner widget rounds the corners whose widgets show (all four, or the Direct-Selected
|
||||
ones), outlining them in red once they reach that limit; Alt-clicking one cycles their kind and double-clicking one
|
||||
opens Corners (`ui.corners {id?, corners?}`, dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
|
||||
`object.setLiveShape {id?, ids?, radius?, kind?, corners?}` sets the corners of any path (one undo step): a live
|
||||
rectangle's or polygon's, a star's, a pen path's. A corner is an anchor without handles between two straight sides
|
||||
(not an open path's ends, not a smooth anchor, not one the sides run straight on through). `radius` (pt) and `kind`
|
||||
(`round`, `invertedRound` or `chamfer`) go to the `corners` given, else to the corners holding a Direct-Selected
|
||||
anchor (`select.anchors`), else to every corner. `corners` are anchor indices of the path with its corners uncut,
|
||||
counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a
|
||||
polygon's from its first vertex clockwise; a star's from its first tip; an index past the last anchor is an error.
|
||||
Each corner keeps its own radius and kind (the shape's `live` in queries has `radii` and, when a corner isn't round,
|
||||
`kinds`); a corner with no radius is one anchor, a cut one two, and Direct-Selected corners stay selected as that
|
||||
changes. A path that isn't a live shape keeps its uncut outline (`live` `{"shape": "path", "base", "radii"}`) so its
|
||||
corners stay editable, and is a plain path again once no corner is cut; a polygon stays a live polygon, its corners
|
||||
keeping the radius they shared when `sides` changes. Every corner is a circular arc tangent to both sides: a radius
|
||||
draws no larger than takes the cut halfway along the corner's shorter side (half the shorter side of a rectangle, the
|
||||
same limit for its four corners), the corners stay circular through uneven scales (the radius scales by the mean scale,
|
||||
or keeps its size with Scale Corners off), and a rectangle from a file that kept an uneven scale in its transform
|
||||
(elliptical corners) gets circular ones in document units when its corners are next set. Dragging a corner widget
|
||||
rounds the corners whose widgets show (every corner, or the Direct-Selected ones), outlining in red those that reach
|
||||
their limit; the Selection tool shows the widgets of live rectangles and polygons, the Direct Selection tool those of
|
||||
any path. Alt-clicking one cycles their kind and double-clicking one opens Corners (`ui.corners {id?, corners?}`,
|
||||
dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"id":12,"corners":[1],"radius":16}}}
|
||||
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"id":12,"corners":[0,3],"radius":8,"kind":"chamfer"}}}
|
||||
{"name":"run_command","arguments":{"command":"shape.star","params":{"cx":200,"cy":200,"radius1":60,"radius2":30}}}
|
||||
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"radius":5}}}
|
||||
```
|
||||
|
||||
## Use Preview Bounds
|
||||
|
||||
Reference in new issue
Block a user