Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 4c6ac00e72 clippy: a byte string for the envelope test's newline (byte_char_slices, red on main with Rust 1.99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:09:35 -04:00
BFlat 2e2bb33909 Merge pull request #203 from storytold/m8-envelope-editing
M8.17: Edit envelopes on the canvas (M8 done)
2026-10-06 12:23:30 +07:00
bflatasticandClaude Opus 5.5 5bb273487e ROADMAP: M8 done (M8.1-M8.20), parity re-derived
The M8 row is done with what the fidelity pass delivered and what is left; the
Symbols/blends/envelopes/perspective parity row moves from 75% to 88%; feature
parity ~75%; Shipped so far and the README status follow.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #175.

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

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

Fixes #173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 12:10:36 -07:00
112 changed files with 15453 additions and 1398 deletions

No files matched your search

+1 -1
View File
@@ -169,7 +169,7 @@ The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`]
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
milestones, and honest time-to-parity estimates.
**Where we are (2026-10-05):** roughly 68–74% of Illustrator's features exist and work, and about 40–55% of
**Where we are (2026-10-06):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The scores are
+11 -11
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-05 (after M4.14–M4.98 and M14.4–M14.6; see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-06 (after M4.14–M4.98, M8.1–M8.20 and M14.4–M14.7; see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -12,10 +12,10 @@ _Last updated: 2026-10-05 (after M4.14–M4.98 and M14.4–M14.6; see [Honest as
|---|---|
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
| Feature surface vs full Illustrator (weighted, see below) | **~74%** on the rubric · **68–74%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~230–350 h** one agent · **~60–100 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~335–520 h** one agent · **~85–150 h** with 4–6 agents |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~225–345 h** one agent · **~60–100 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~330–515 h** one agent · **~85–150 h** with 4–6 agents |
## Honest assessment (2026-10-05)
@@ -34,7 +34,7 @@ since 2026-10-01.
| Dimension | Estimate | Evidence and what's missing |
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type and vertical type ×3; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~68–74% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~35%), brushes and symbols (in progress), raster effects (~20%) |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~35%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, vertical/CJK type, Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
@@ -112,7 +112,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
@@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
@@ -153,7 +153,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~335–520** |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~330–515** |
## Parity estimate
@@ -180,7 +180,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
@@ -191,10 +191,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| **Feature parity** | **103** | **~74%** | | **~230–350** |
| **Feature parity** | **103** | **~75%** | | **~225–345** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
| **Full parity** | | **~55%** | | **~335–520** |
| **Full parity** | | **~56%** | | **~330–515** |
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
(integration, review and shared files such as `menus.rs` serialize some work): **~60–100 h** to feature parity,
+8 -1
View File
@@ -547,7 +547,14 @@ fn edge(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
ctrl.push(c.point(10)?);
weights.push(c.num(42).unwrap_or(1.0));
}
let fit: Vec<Point> = match c.count(97) {
// Fit data (DXF 2010 and later): a count, then that many points. Older files have none,
// and the 97 that may follow is the boundary's count of source objects (then their
// handles), so it is only the fit count when it is 0 or points follow.
let fit_count = match (c.g.get(c.i), c.g.get(c.i + 1)) {
(Some(count), next) if count.code == 97 => number(count.value) == Some(0.0) || next.is_some_and(|p| p.code == 11),
_ => false,
};
let fit: Vec<Point> = match fit_count.then(|| c.count(97)).flatten() {
Some(n) => (0..n).map(|_| c.point(11)).collect::<Option<_>>()?,
None => vec![],
};
+35
View File
@@ -319,6 +319,41 @@ fn solid_hatches_fill_and_pattern_hatches_outline() {
assert!(square.appearance.fill().is_some_and(|f| !f.paint.is_none()));
}
/// A solid hatch: a loop of a line and a spline edge (`fit` its fit data, if any), followed by
/// `source` (the loop's source-object count and handles), and a square hole.
fn spline_edged_hatch(fit: &[(i32, &'static str)], source: &[(i32, &'static str)]) -> Vec<(i32, &'static str)> {
let mut e = vec![(0, "HATCH"), (62, "1"), (10, "0"), (20, "0"), (30, "0"), (2, "SOLID"), (70, "1"), (71, "1"), (91, "2")];
e.extend([(92, "1"), (93, "2"), (72, "1"), (10, "0"), (20, "0"), (11, "100"), (21, "0")]);
e.extend([(72, "4"), (94, "3"), (73, "0"), (74, "0"), (95, "8"), (96, "4")]);
e.extend(["0", "0", "0", "0", "1", "1", "1", "1"].map(|k| (40, k)));
e.extend([(10, "100"), (20, "0"), (10, "100"), (20, "100"), (10, "0"), (20, "100"), (10, "0"), (20, "0")]);
e.extend_from_slice(fit);
e.extend_from_slice(source);
e.extend([(92, "2"), (72, "0"), (73, "1"), (93, "4"), (10, "40"), (20, "20"), (10, "60"), (20, "20")]);
e.extend([(10, "60"), (20, "40"), (10, "40"), (20, "40"), (97, "0"), (75, "0"), (76, "1"), (98, "0")]);
e
}
#[test]
fn hatch_spline_edges_with_or_without_fit_data() {
// Before DXF 2010 a spline edge has no fit data, so a 97 right after it is the loop's own
// count of source objects; from 2010 on it has a fit count, here 0, before that.
let source = [(97, "1"), (330, "1F")];
for fit in [&[][..], &[(97, "0")][..]] {
let r = open(&entities(&spline_edged_hatch(fit, &source)), &points());
let [hatch] = &r.document.layers[0].children().unwrap()[..] else { panic!("fit data {fit:?}: the hatch is lost") };
let NodeKind::Compound { children, .. } = &hatch.kind else { panic!("{:?}", hatch.kind) };
assert_eq!(children.len(), 2, "the outline and the hole, fit data {fit:?}");
// The spline edge peaks three quarters of the way up its control polygon.
let b = bounds(hatch);
assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 75.0).abs() < 1e-6, "{b:?}");
}
// Fit points after the count are still read (and ignored when there are control points).
let fit = [(97, "2"), (11, "100"), (21, "0"), (11, "0"), (21, "0")];
let r = open(&entities(&spline_edged_hatch(&fit, &source)), &points());
assert_eq!(r.document.layers[0].children().unwrap().len(), 1);
}
#[test]
fn text_and_multiline_text() {
let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Notes"), (3, "DejaVuSans.ttf"), (0, "ENDTAB")];
+1 -1
View File
@@ -316,7 +316,7 @@ impl<'a> Scene<'a> {
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
}
+24
View File
@@ -892,6 +892,30 @@ impl Appearance {
g.transform(a);
}
}
/// Envelope Options → Distort Linear Gradients: bend the linear gradients of an object whose
/// geometric bounds are `bounds` with a warp, given its local affine approximation at a point
/// (taken at the middle of each gradient's vector). Unplaced ones are fixed to their fit first;
/// other gradients stay as they are.
pub fn warp_linear_gradients(&mut self, bounds: vectorcraft_geom::Rect, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) {
for i in &mut self.items {
let (g, b) = match i {
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) => (g, bounds),
AppearanceItem::Stroke(s) => {
let b = s.paint_bounds(bounds);
let Paint::Gradient(g) = &mut s.paint else { continue };
(g, b)
}
_ => continue,
};
if g.gradient.kind != vectorcraft_color::GradientKind::Linear {
continue;
}
g.pin(b);
if let Some(geom) = g.geom {
g.transform(near(geom.start.midpoint(geom.end)));
}
}
}
/// Map placed gradients through a warp, given its local affine approximation at a point (taken
/// at each gradient's centre: the start of a radial, the middle of a linear vector). Freeform
/// points each follow the warp at their own position.
File diff suppressed because it is too large. Load diff
+23 -6
View File
@@ -20,15 +20,18 @@ pub struct Hit {
pub leaf: NodeId,
pub ancestry: Vec<NodeId>,
pub kind: HitKind,
/// The innermost envelope in the ancestry whose contents are being edited (Edit Contents):
/// its content, not the envelope, is the object clicked.
pub contents_of: Option<NodeId>,
}
impl Hit {
/// The object the Selection tool selects: the child of the layer (the outermost group).
/// Inside isolation mode (`scope`), the child of the isolated container instead.
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
if let Some(s) = scope
&& let Some(i) = self.ancestry.iter().position(|x| *x == s)
{
// The innermost of the isolated container and an envelope whose contents are edited.
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
if let Some(i) = at(scope).max(at(self.contents_of)) {
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
}
// Skip layers and sublayers.
@@ -68,7 +71,8 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
continue;
}
chain.push(layer.id);
if let Some(h) = hit_children(layer, p, opt, &mut chain) {
if let Some(mut h) = hit_children(layer, p, opt, &mut chain) {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
return Some(h);
}
chain.pop();
@@ -76,6 +80,11 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
None
}
/// Is `n` an envelope whose contents are being edited (they hit, not the envelope)?
fn edits_contents(n: &Node) -> bool {
matches!(n.kind, NodeKind::Envelope { editing: true, .. })
}
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
/// glyph outlines need the font engine, above this crate.
fn clip_contains(clip: &Node, p: Point) -> bool {
@@ -99,7 +108,9 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
if !c.visible || c.locked {
continue;
}
if let Some(b) = c.reach_bounds()
// (An envelope's content sits where it was, not where the envelope draws it.)
if !edits_contents(c)
&& let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
continue;
@@ -107,7 +118,13 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
chain.push(c.id);
let hit = match &c.kind {
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }),
// Edit Contents: the envelope's content hits, undistorted.
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain),
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
// as the blend.
NodeKind::Blend { .. } => hit_children(c, p, opt, chain)
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None })),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None }),
};
if hit.is_some() {
return hit;
+10
View File
@@ -7,6 +7,7 @@
pub mod appearance;
pub mod assets;
pub mod blend;
pub mod clipnest;
pub mod graph;
pub mod hit;
@@ -19,8 +20,10 @@ pub mod node;
pub mod orient;
pub mod overprint;
pub mod pattern;
pub mod perspective;
mod pixels;
pub mod profiles;
pub mod puppet;
pub mod range;
pub mod rastersettings;
mod reach;
@@ -65,7 +68,9 @@ pub use node::{ImageMap, ObjectAttributes};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use orient::OrientedBox;
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use perspective::PerspectiveAttachment;
pub use profiles::ColorProfiles;
pub use puppet::{PuppetPin, PuppetPins};
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
pub use selection::{AnchorRef, Selection};
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
@@ -563,6 +568,10 @@ pub struct Document {
/// export settings are Export for Screens' ([`Document::export_settings`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<ExportAsset>,
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
/// state, never saved.
#[serde(skip)]
pub puppet: Option<Arc<PuppetPins>>,
}
fn ppi72() -> f64 {
@@ -630,6 +639,7 @@ impl Document {
slices_clip_to_artboard: true,
print_setup: None,
assets: vec![],
puppet: None,
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
+603 -428
View File
File diff suppressed because it is too large. Load diff
+26 -5
View File
@@ -272,6 +272,14 @@ pub enum NodeKind {
/// Edit Contents mode (the content, not the envelope, is edited).
#[serde(default)]
editing: bool,
/// Envelope Options besides Fidelity.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
options: crate::live::EnvelopeOptions,
/// The envelope's own axes (→ the document): the transforms it took since it was made that
/// the page axes can't stand for (rotation, shear, reflection). The content maps from its
/// bounds in this frame, and a warp bends along it.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
frame: Affine,
},
/// Gradient mesh.
Mesh(GradientMesh),
@@ -340,6 +348,9 @@ pub struct Node {
/// [`crate::orient`]); Reset Bounding Box sets it back to 0.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub bbox_angle: f64,
/// Object › Perspective: the perspective grid plane the object is attached to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
}
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
@@ -389,6 +400,7 @@ impl Node {
attrs: None,
slice: None,
bbox_angle: 0.0,
perspective: None,
}
}
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
@@ -522,14 +534,14 @@ impl Node {
.iter()
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
NodeKind::Text(t) => t.bounds(),
NodeKind::Text(t) => self.projected(t.bounds()),
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
NodeKind::SymbolInstance { xf, .. } => Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0))),
NodeKind::SymbolInstance { xf, .. } => self.projected(Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0)))),
NodeKind::Blend { children, spec } => {
let b = crate::live::nodes_bounds(children);
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
}
NodeKind::Envelope { content, kind, .. } => crate::live::envelope_bounds(content, kind),
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame),
NodeKind::Mesh(m) => m.bounds(),
NodeKind::Repeat(r) => r.bounds(),
}
@@ -581,6 +593,8 @@ impl Node {
if !self.is_layer() {
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
}
// Type and symbols in perspective keep looking the same, moved by `a`.
self.transform_projection(a);
// Refitting an unplaced gradient only reproduces moves and uniform scales.
if !keeps_gradient_fit(a) {
self.pin_gradients();
@@ -632,15 +646,22 @@ impl Node {
s.transform(a);
}
}
NodeKind::Envelope { content, kind, .. } => {
NodeKind::Envelope { content, kind, frame, .. } => {
for c in content.iter_mut() {
Arc::make_mut(c).transform_scaled(a, sc);
}
*frame = crate::live::envelope_frame(a * *frame);
match kind {
EnvelopeKind::Mesh { points, .. } => {
EnvelopeKind::Mesh { points, handles, .. } => {
for p in points.iter_mut() {
*p = a * *p;
}
// Handles are offsets: they take the linear part.
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let lin = Affine::new([m0, m1, m2, m3, 0.0, 0.0]);
for h in handles.iter_mut().flatten() {
*h = (lin * h.to_point()).to_vec2();
}
}
EnvelopeKind::TopObject { path } => path.transform(a),
EnvelopeKind::Warp { .. } => {}
+69
View File
@@ -0,0 +1,69 @@
//! Perspective Grid attachments (Object › Perspective).
//!
//! The grid itself is document data (`Document.unknown["perspectiveGrid"]`, modelled by the tools
//! crate). Which plane an object lies on is kept on the object, so copies, duplicates and pastes
//! stay attached and deleted objects leave nothing stale behind.
use serde::{Deserialize, Serialize};
use vectorcraft_geom::{Homography, Rect};
use crate::{Node, NodeKind};
/// The plane an object in perspective lies on.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PerspectiveAttachment {
/// `left`, `right` or `ground`.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub plane: String,
/// Where along the plane's normal (points) the parallel plane the object lies on is: 0 is the
/// grid plane in its original place.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub depth: f64,
/// Type and symbol instances: the projective map (row-major 3 × 3) that draws their flat art
/// in perspective. They stay type and symbol instances; the renderer and exporters project
/// their outlines.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection: Option<[f64; 9]>,
/// Object › Perspective › Edit Text in progress: the type is drawn flat to be edited (not
/// saved).
#[serde(skip)]
pub editing: bool,
}
impl PerspectiveAttachment {
pub fn new(plane: &str, depth: f64) -> Self {
Self { plane: plane.to_string(), depth: if depth.is_finite() { depth } else { 0.0 }, ..Self::default() }
}
/// The projection, when it is a valid one.
pub fn homography(&self) -> Option<Homography> {
self.projection.and_then(Homography::from_array)
}
}
impl Node {
/// The projective map type or a symbol instance in perspective is drawn through (not while
/// Edit Text shows it flat).
pub fn projection(&self) -> Option<Homography> {
let a = self.perspective.as_deref().filter(|a| !a.editing)?;
matches!(self.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }).then(|| a.homography()).flatten()
}
/// `b` (flat bounds) as drawn: the box of its projection for type and symbols in perspective.
pub(crate) fn projected(&self, b: Option<Rect>) -> Option<Rect> {
match (self.projection(), b) {
(Some(h), Some(b)) => h.map_rect_bbox(b).or(Some(b)),
(_, b) => b,
}
}
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too).
pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) {
if let Some(p) = self.perspective.as_deref_mut()
&& let Some(h) = p.homography().and_then(|h| h.conjugated(a))
{
p.projection = Some(h.to_array());
}
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Puppet Warp pins: editing state the Puppet Warp tool and `object.puppetWarp` share while the
//! same artwork stays selected. It lives in the document (never saved) so Undo and Redo take the
//! pins back with the warps they made.
use std::sync::Arc;
use vectorcraft_geom::Point;
use crate::{Document, Node, NodeId};
/// One pin: where it sits on the rest shape, where it is now, and the turn it holds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PuppetPin {
pub rest: Point,
pub at: Point,
/// Radians the art is turned around the pin; `None`: free to turn.
pub angle: Option<f64>,
}
/// The pins on some objects and the shape they warp from.
#[derive(Clone, Debug, PartialEq)]
pub struct PuppetPins {
/// The warped objects.
pub ids: Vec<NodeId>,
/// Their rest shape: the objects as they were when pinning began (the warp always starts
/// from it, so warps don't stack and dragging a pin back restores the original).
pub rest: Vec<Arc<Node>>,
/// The objects as the last warp left them: once anything else edits them, the pins start
/// again from the objects as they are.
pub result: Vec<Arc<Node>>,
pub pins: Vec<PuppetPin>,
}
impl PuppetPins {
/// The pins are still those of `ids` in `doc`: the same objects, as the last warp left them.
pub fn holds(&self, doc: &Document, ids: &[NodeId]) -> bool {
self.ids == ids
&& self.ids.len() == self.result.len()
&& ids.iter().zip(&self.result).all(|(id, r)| doc.node_arc(*id).is_some_and(|a| Arc::ptr_eq(a, r)))
}
}
impl Document {
/// The node `id` as the document holds it (shared: comparing pointers says whether it changed).
pub fn node_arc(&self, id: NodeId) -> Option<&Arc<Node>> {
fn find(nodes: &[Arc<Node>], id: NodeId) -> Option<&Arc<Node>> {
for n in nodes {
if n.id == id {
return Some(n);
}
if let Some(f) = n.children().and_then(|ch| find(ch, id)) {
return Some(f);
}
}
None
}
find(&self.layers, id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Appearance;
use vectorcraft_geom::{Rect, shapes};
#[test]
fn pins_hold_until_their_objects_change_and_are_never_saved() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap();
let node = d.node_arc(id).cloned().unwrap();
let pin = PuppetPin { rest: Point::new(5.0, 5.0), at: Point::new(6.0, 5.0), angle: Some(0.5) };
d.puppet = Some(Arc::new(PuppetPins { ids: vec![id], rest: vec![node.clone()], result: vec![node], pins: vec![pin] }));
let pins = d.puppet.clone().unwrap();
assert!(pins.holds(&d, &[id]));
assert!(!pins.holds(&d, &[id, l]), "other objects");
// Saved and read back without them (editing state).
let json = serde_json::to_string(&d).unwrap();
assert!(!json.contains("puppet"));
let back: Document = serde_json::from_str(&json).unwrap();
assert!(back.puppet.is_none());
assert_eq!(back.node(id), d.node(id));
// An edit by anything else: the pins no longer hold.
d.node_mut(id).unwrap().name = Some("moved".into());
assert!(!pins.holds(&d, &[id]));
}
}
+45 -2
View File
@@ -14,7 +14,7 @@
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use crate::group::{has_own_paint, paints};
@@ -44,6 +44,7 @@ pub fn needs_bake(n: &Node) -> bool {
|| has_pathfinder(n)
|| has_own_paint(n)
|| has_geometry(&n.appearance.effects)
|| n.projection().is_some()
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c)))
}
@@ -92,7 +93,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
return Some(bake_pieces(d, m));
}
// Type, images, symbol instances and live objects: reshaped through their outlines.
if needs_outline(n) && has_geometry(&n.appearance.effects) {
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
@@ -281,6 +282,48 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
}
}
/// Envelope Options → Distort Appearance: path or compound path `n` as plain filled art that an
/// envelope bends as a whole: its geometry effects applied, every visible fill a copy of the path
/// painted by that fill and every visible stroke without a brush its filled outline in the
/// stroke's paint, opacity and blend mode (a brushed stroke stays a stroke), in paint order, as
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
pub fn bake_appearance(n: &Node) -> Option<Node> {
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
return None;
}
let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none());
let geometric = has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i)));
if !geometric && !n.appearance.items.iter().any(outlined) {
return None;
}
let (g, rule, ctx, mut m) = leaf_base(n)?;
let piece = |path: PathData, rule: FillRule, item: AppearanceItem| {
let mut p = Node::path(n.id, path, Appearance { items: vec![item], ..Default::default() });
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
*r = rule;
}
Arc::new(p)
};
let mut pieces = vec![];
for item in n.appearance.items.iter().filter(|i| paints(i)) {
let ig = apply(item_effects(item), &g, &ctx.item(item));
let mut it = item.clone();
clear_item_effects(&mut it);
pieces.push(match &it {
AppearanceItem::Stroke(st) if st.brush.is_none() => {
let fill = FillLayer { opacity: st.opacity, blend: st.blend, ..FillLayer::new(st.paint.clone()) };
piece(crate::stroke::outline_region(&ig, rule, st), FillRule::NonZero, AppearanceItem::Fill(fill))
}
_ => piece(ig, rule, it),
});
}
m.appearance.items.clear();
m.appearance.contents_index = None;
m.kind = NodeKind::Group { children: pieces, clip: false };
Some(m)
}
/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the
/// document has none (export it as is).
pub fn bake_document(doc: &Document) -> Option<Document> {
+3 -1
View File
@@ -34,6 +34,7 @@ mod bake;
mod clip;
mod distort;
mod group;
mod live;
mod marks;
mod raster;
mod reshape;
@@ -52,12 +53,13 @@ use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use clip::clip_outline;
pub use group::{
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints,
pathfinder_children,
};
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
+57
View File
@@ -0,0 +1,57 @@
//! Live objects (blends, envelopes, gradient meshes, repeats) evaluated alike for the renderer and
//! every exporter, with what the pure evaluation in [`vectorcraft_doc::live`] can't do itself
//! ([`Hooks`]): type inside an envelope distorts as its glyph outlines, run by run
//! ([`outline_text`]); symbol instances as their symbol's art; with Distort Appearance strokes and
//! geometry effects bend with the art ([`bake_appearance`]); with Distort Pattern Fills the pattern
//! tiles do.
use std::sync::Arc;
use vectorcraft_doc::live::{self, Hooks, Outliner};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::{Affine, Rect};
use crate::{bake_appearance, outline_text};
/// The text outliner live evaluation uses: type as its glyph outlines, one path per run in the
/// run's paint ([`outline_text`]).
pub fn text_outliner() -> Outliner<'static> {
Some(&outline_text)
}
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance.
fn outline(doc: Option<&Document>, n: &Node) -> Option<Node> {
match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => {
let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?;
let mut art = (*sym.art).clone();
art.transform(*xf, false);
Some(Node::group(n.id, vec![Arc::new(art)]))
}
_ => outline_text(n),
}
}
/// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a
/// document, symbol instances and pattern tiles stay as they are).
fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
let outline = |n: &Node| outline(doc, n);
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles) })
}
/// One level of evaluation of live object `n` ([`live::expand_live_hooks`]).
pub fn expand_live(doc: Option<&Document>, n: &Node) -> Vec<Node> {
with_hooks(doc, |h| live::expand_live_hooks(n, h))
}
/// Live object `n` evaluated into a plain group ([`live::expanded_group_hooks`]): Expand.
pub fn expanded_live_group(doc: Option<&Document>, n: &Node) -> Node {
with_hooks(doc, |h| live::expanded_group_hooks(n, h))
}
/// `n` with every live object in it replaced by its evaluated art ([`live::expand_deep_hooks`]):
/// what the exporters write for live objects.
pub fn expand_live_deep(doc: Option<&Document>, n: &Node) -> Node {
with_hooks(doc, |h| live::expand_deep_hooks(n, h))
}
+32 -1
View File
@@ -113,7 +113,7 @@ fn outline_nested_text(n: &mut Node) {
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if geometry_effects(n).is_empty() || !needs_outline(n) {
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
return None;
}
as_art(n, symbol_art)
@@ -146,6 +146,10 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
_ => outline_art(n, symbol_art)?,
};
reshape_leaves(&mut art, &fx);
// Type and symbols in perspective: the art projected onto their plane.
if let Some(h) = n.projection() {
project_leaves(&mut art, &h);
}
// The art is a group with `n`'s id (a clip group for an image).
let mut out = art;
out.name = n.name.clone();
@@ -159,6 +163,33 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
Some(out)
}
/// Map every path under `art` (and its gradients) through the projective map `h`, curves split
/// finely enough to follow it.
fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography) {
let f = |p: vectorcraft_geom::Point| h.apply(p).unwrap_or(p);
let piece = art.geometric_bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25));
let project = |path: &mut PathData| *path = vectorcraft_doc::live::map_nonlinear(path, piece, f);
for_each_leaf(art, &mut |leaf| {
leaf.pin_gradients();
leaf.appearance.warp_gradients(&|p| h.affine_at(p).unwrap_or(vectorcraft_geom::Affine::IDENTITY));
match &mut leaf.kind {
NodeKind::Path { path, live, .. } => {
project(path);
*live = None;
}
NodeKind::Compound { children, .. } => {
for c in children.iter_mut() {
if let NodeKind::Path { path, live, .. } = &mut Arc::make_mut(c).kind {
project(path);
*live = None;
}
}
}
_ => {}
}
});
}
/// The visible geometry effects of `n`'s own effect list, in order.
pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect()
+663 -109
View File
@@ -7,13 +7,13 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind, WidthProfile};
use vectorcraft_geom::Point;
use vectorcraft_tools::distort::liquify::{LiquifyParams, apply_stroke, dabs};
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
use vectorcraft_tools::distort::{arap, collect_points, mesh_for, warp_node_with};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PuppetPin, WidthProfile};
use vectorcraft_geom::{Affine, Homography, PathData, Point, Rect};
use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample, reach_bounds};
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with};
use super::edit::selected_roots;
use super::edit::{duplicate_in, selected_roots};
use super::*;
use crate::EngineError;
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Liquify",
[],
None,
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y]…] (the brush stroke), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), detail? (1..10), simplify? (0..100), rate? (twirl °), complexity?, horizontal?, vertical?, affectAnchors?, affectIn?, affectOut?, ids? (default: selection, else every path the brush touches)}",
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify? (0..100) and simplifyOn? (default true; warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids? (default: selection, else every path the brush touches)}",
has_doc,
liquify
),
@@ -69,16 +69,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Puppet Warp",
[],
None,
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), expand?: pt} as-rigid-as-possible mesh warp of anchors and handles",
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)",
has_doc,
puppet_warp
),
cmd!(
"object.puppetWarp.pins",
"Puppet Warp Pins",
[],
None,
"{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp",
has_doc,
puppet_pins
),
cmd!(
"perspective.grid.set",
"Define Perspective Grid",
[],
None,
"{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?} merge into the document's grid",
"{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?, leftOffset?, rightOffset?, groundOffset?: pt (planes moved along their normals), reproject?: bool} merge into the document's grid. Objects in perspective stay where they are, as in the reference app; with reproject they move with the grid (each keeps its place on its plane)",
has_doc,
grid_set
),
@@ -87,9 +96,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Perspective Grid Preset",
[],
None,
"{kind: 1|2|3} reset the grid to the one/two/three-point preset for the first artboard (and show it)",
"{kind: 1|2|3 (its normal view) | name: a preset (see perspective.presets.list)} reset the grid to the preset fitted to the first artboard (and show it; attached objects stay attached)",
has_doc,
grid_preset
super::perspgrid::grid_preset
),
cmd!(
"perspective.grid.show",
@@ -124,7 +133,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move in Perspective",
[],
None,
"{ids?, from: [x,y], to: [x,y], plane?} slide objects within their plane (unattached objects attach to `plane`/the active plane first)",
"{ids?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it",
has_doc,
persp_move
),
@@ -133,10 +142,55 @@ pub fn specs() -> Vec<CommandSpec> {
"Draw in Perspective",
[],
None,
"{command: shape.* id, params, plane?} run a shape command and attach the result to the plane",
"{command: shape.* id, params, plane?, snap?: bool (default: Snap to Grid)} run a shape command and attach the result to the plane (snapping its corners to gridlines within a quarter cell)",
has_doc,
persp_draw
),
cmd!(
"perspective.transform",
"Transform in Perspective",
[],
None,
"{ids?, matrix: [a,b,c,d,e,f] (affine map in plane coordinates, points: u along the plane, v up or away), depth?: pt to move along the plane's normal (default 0), copy?: bool, plane?} transform objects within their own planes (Perspective Selection tool handles; unattached objects attach to `plane`/the active plane first); Transform Again repeats it → {ids}",
has_doc,
persp_transform
),
cmd!(
"perspective.nudge",
"Nudge in Perspective",
[],
None,
"{dx, dy: arrow direction (-1, 0 or 1), big?: bool (×10), copy?: bool} move the selection in perspective by the keyboard increment, as the arrow keys do with the Perspective Selection tool → {ids}",
has_selection,
persp_nudge
),
cmd!(
"perspective.plane.move",
"Move Plane",
[],
None,
"{plane?: left|right|ground (default: the active plane), offset?: pt (where along its normal; 0 is its place in the grid's definition) | by?: pt, objects?: none|move|copy (default none: the objects on the plane stay; move: they move with it, as Shift-dragging a plane widget does; copy: copies of them do, as Alt-dragging does)} → {plane, offset, ids}",
has_doc,
plane_move
),
cmd!(
"perspective.plane.matchObject",
"Move Plane to Match Object",
[],
None,
"{id? (default: the first selected object)} move the plane the object is attached to onto the object and make it the active plane → {plane, offset}",
has_doc,
plane_match
),
cmd!(
"perspective.editText",
"Edit Text",
[],
None,
"{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again → {id}",
has_doc,
edit_text
),
]
}
@@ -149,10 +203,16 @@ fn uniform() -> WidthProfile {
/// Width points closer than this (in t) are at the same place: a discontinuous point.
const SAME_T: f64 = 1e-6;
/// The weighted stroke of `id` that width point commands edit.
fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> {
/// The stroke of `id` that width point commands edit (a compound path's for its members).
fn stroke_mut(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> {
let id = stroke_owner(d, id);
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?;
n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))
}
/// [`stroke_mut`], which must have a weight.
fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> {
let st = stroke_mut(d, id)?;
if st.width <= 0.0 {
return Err(EngineError::Other("the stroke has no weight".into()));
}
@@ -271,8 +331,7 @@ fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
indices.sort_unstable();
indices.dedup();
s.edit("Delete Width Point", |d, _| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?;
let st = stroke_mut(d, id)?;
let fits = |pr: &&mut WidthProfile| indices.last().is_some_and(|i| *i < pr.points.len());
let prof = st.profile.as_mut().filter(fits).ok_or_else(|| bad(C, "no such width point"))?;
for i in indices.iter().rev() {
@@ -306,7 +365,10 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad(C, "points must be an array or null")),
};
let ids = targets(s, p)?;
let doc = &s.doc()?.doc;
let mut ids: Vec<NodeId> = targets(s, p)?.into_iter().map(|id| stroke_owner(doc, id)).collect();
ids.sort_unstable();
ids.dedup();
s.edit("Width Profile", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
@@ -325,90 +387,278 @@ fn points_of(p: &Value, key: &str) -> Option<Vec<Point>> {
p.get(key)?.as_array()?.iter().map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect()
}
/// Leaf paths under `roots` that can be edited.
fn leaf_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for r in roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { .. }) && !out.contains(&c.id) {
out.push(c.id);
}
});
/// A stroke's `[[x, y, pressure?]…]` samples (pressure 0..1, default 1).
fn samples_of(p: &Value, key: &str) -> Option<Vec<Sample>> {
p.get(key)?
.as_array()?
.iter()
.map(|q| Some((Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?), q.get(2).map_or(Some(1.0), Value::as_f64)?)))
.collect()
}
/// What a liquify stroke may reach: the paths it can edit, each with the box no dab outside
/// changes it ([`reach_bounds`]), and the objects it leaves as they are (type, symbols, images,
/// graphs, meshes, and envelopes, repeats and blends with their contents), each with its bounds
/// and what it is. Hidden and locked objects, and guides, are neither.
#[derive(Default)]
struct Targets {
paths: Vec<(NodeId, Rect)>,
blocked: Vec<(NodeId, Rect, &'static str)>,
/// Every object listed (selected roots may hold each other).
seen: std::collections::HashSet<NodeId>,
}
/// What `n` is when Liquify can't distort it (and doesn't look inside it).
fn not_liquified(n: &Node) -> Option<&'static str> {
Some(match &n.kind {
NodeKind::Group { .. } if n.graph.is_some() => "graphs",
NodeKind::Text(_) => "type",
NodeKind::SymbolInstance { .. } => "symbols",
NodeKind::Image(_) => "images",
NodeKind::Mesh(_) => "meshes",
NodeKind::Envelope { .. } => "envelopes",
NodeKind::Repeat(_) => "repeats",
NodeKind::Blend { .. } => "blends",
_ => return None,
})
}
impl Targets {
/// The targets under `roots` (the selection), or with none every visible object.
fn of(d: &Document, roots: &[NodeId]) -> Self {
let mut t = Self::default();
if roots.is_empty() {
d.layers.iter().for_each(|l| t.visit(l));
return t;
}
for r in roots {
let (Some(n), Some(chain)) = (d.node(*r), d.ancestry(*r)) else { continue };
if !d.is_editable(*r) {
continue;
}
// Inside a graph, an envelope, a repeat or a blend: that object is what is left alone.
match chain.iter().filter_map(|a| d.node(*a)).find_map(|a| Some((a, not_liquified(a)?))) {
Some((a, what)) => t.block(a, what),
None => t.visit(n),
}
}
t
}
fn block(&mut self, n: &Node, what: &'static str) {
if let Some(b) = n.geometric_bounds()
&& self.seen.insert(n.id)
{
self.blocked.push((n.id, b, what));
}
}
out.retain(|id| d.is_editable(*id));
out
fn visit(&mut self, n: &Node) {
if !n.visible || n.locked {
return;
}
if let Some(what) = not_liquified(n) {
return self.block(n, what);
}
match &n.kind {
NodeKind::Path { guide: false, path, .. } => {
if let Some(b) = reach_bounds(path)
&& self.seen.insert(n.id)
{
self.paths.push((n.id, b));
}
}
_ => n.children().into_iter().flatten().for_each(|c| self.visit(c)),
}
}
}
/// Does a dab with box `bb` reach `b`?
fn reaches(bb: Rect, b: Rect) -> bool {
b.inflate(1e-6, 1e-6).intersect(bb).area() > 0.0
}
/// A liquify stroke being applied: its dabs and the paths they reached so far, so a longer stroke
/// (the next sample of a drag) applies only the dabs it adds. It belongs to one document snapshot
/// (`base`, held so the pointer can't be reused) and one set of other parameters (`key`).
pub(crate) struct LiquifyStroke {
base: Arc<Document>,
key: Value,
prm: LiquifyParams,
dabs: Dabber,
targets: Targets,
/// The paths reached, by their index in `targets.paths`.
paths: Vec<(usize, PathStroke)>,
/// The objects left alone that the brush passed over, by their index in `targets.blocked`.
skipped: Vec<usize>,
/// Which of `targets.paths` and `targets.blocked` the brush reached.
reached: (Vec<bool>, Vec<bool>),
}
impl LiquifyStroke {
fn new(base: Arc<Document>, key: Value, prm: LiquifyParams, roots: &[NodeId]) -> Self {
let targets = Targets::of(&base, roots);
let reached = (vec![false; targets.paths.len()], vec![false; targets.blocked.len()]);
Self { base, key, prm, dabs: Dabber::new(&prm), targets, paths: vec![], skipped: vec![], reached }
}
/// Start the targets dab box `bb` reaches.
fn reach(&mut self, bb: Rect) {
for (i, (id, b)) in self.targets.paths.iter().enumerate() {
if let Some(r) = self.reached.0.get_mut(i).filter(|r| !**r && reaches(bb, *b)) {
*r = true;
let Some(path) = self.base.node(*id).and_then(|n| n.path_data()) else { continue };
self.paths.push((i, PathStroke::new(path.clone(), id.0)));
}
}
for (i, (_, b, _)) in self.targets.blocked.iter().enumerate() {
if let Some(r) = self.reached.1.get_mut(i).filter(|r| !**r && reaches(bb, *b)) {
*r = true;
self.skipped.push(i);
}
}
}
/// Add `samples` to the stroke and apply the dabs they make.
fn extend(&mut self, samples: &[Sample]) {
let from = self.dabs.dabs.len();
for s in samples {
self.dabs.push(*s);
}
for i in from..self.dabs.dabs.len() {
if let Some(c) = self.dabs.dabs.get(i).map(|d| d.c) {
self.reach(self.prm.brush_bounds(c));
}
}
if let Some(t) = self.dabs.tail() {
self.reach(self.prm.brush_bounds(t.c));
}
for (_, ps) in &mut self.paths {
ps.advance(&self.dabs.dabs, &self.prm);
}
}
/// The paths the stroke changed (in document order) and their new geometry.
fn results(&self) -> Vec<(NodeId, PathData)> {
let tail = self.dabs.tail();
let mut out: Vec<(usize, NodeId, PathData)> =
self.paths.iter().filter_map(|(i, ps)| Some((*i, self.targets.paths.get(*i)?.0, ps.finish(&self.dabs.dabs, tail, &self.prm)?))).collect();
out.sort_by_key(|(i, ..)| *i);
out.into_iter().map(|(_, id, p)| (id, p)).collect()
}
/// The objects under the brush left as they are, and the status message about them.
fn skipped(&self) -> (Vec<u64>, Option<String>) {
let mut ids = vec![];
let mut kinds: Vec<&str> = vec![];
for (id, _, what) in self.skipped.iter().filter_map(|i| self.targets.blocked.get(*i)) {
ids.push(id.0);
if !kinds.contains(what) {
kinds.push(what);
}
}
let msg = (!ids.is_empty()).then(|| {
let n = ids.len();
format!(
"{} left {n} object{} under the brush as {} ({}): it reshapes paths only",
self.prm.kind.label(),
if n == 1 { "" } else { "s" },
if n == 1 { "it was" } else { "they were" },
kinds.join(", ")
)
});
(ids, msg)
}
}
fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.liquify";
let prm = LiquifyParams::from_json(p).ok_or_else(|| bad(C, "missing or unknown `tool`"))?;
let pts = points_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y]…] list"))?;
if pts.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) {
let pts = samples_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y,pressure?]…] list"))?;
if pts.iter().any(|(q, f)| !q.x.is_finite() || !q.y.is_finite() || !f.is_finite()) {
return Err(bad(C, "points must be finite"));
}
let dab_pts = dabs(&pts, prm.dab_spacing());
let roots = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => v,
None => s.doc()?.selection.objects.clone(),
};
let doc = &s.doc()?.doc;
let leaves = if roots.is_empty() {
// Nothing selected: everything the brush sweeps over.
// No dabs sweep nothing.
let sweep = dab_pts.iter().map(|c| prm.brush_bounds(*c)).reduce(|a, b| a.union(b)).unwrap_or(vectorcraft_geom::Rect::ZERO);
let mut all = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { guide: false, .. })
&& n.geometric_bounds().is_some_and(|b| b.inflate(1e-6, 1e-6).intersect(sweep).area() > 0.0)
{
all.push(n.id);
}
});
all.retain(|id| doc.is_editable(*id) && doc.is_visible(*id));
all
} else {
leaf_paths(doc, &roots)
let base = s.doc()?.doc.clone();
let mut key = p.clone();
if let Some(o) = key.as_object_mut() {
o.remove("points");
o.insert("__roots".into(), json!(roots.iter().map(|r| r.0).collect::<Vec<_>>()));
}
// A live drag: the same stroke as the last preview with more samples goes on from there.
let mut stroke = match s.liquify_stroke.take() {
Some(st) if Arc::ptr_eq(&st.base, &base) && st.key == key && pts.starts_with(st.dabs.samples()) => st,
_ => Box::new(LiquifyStroke::new(base, key, prm, &roots)),
};
let done = stroke.dabs.samples().len();
stroke.extend(pts.get(done..).unwrap_or_default());
let results = stroke.results();
let (skipped, warning) = stroke.skipped();
let changed = s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
let mut changed = vec![];
for id in &leaves {
let Some(n) = d.node_mut(*id) else { continue };
if let NodeKind::Path { path, live, .. } = &mut n.kind
&& apply_stroke(path, &dab_pts, &prm, id.0)
{
for (id, new) in results {
let Some(n) = d.node_mut(id) else { continue };
if let NodeKind::Path { path, live, .. } = &mut n.kind {
*path = new;
*live = None;
changed.push(id.0);
}
}
Ok(changed)
})?;
Ok(json!({ "ids": changed }))
if s.in_interaction() {
s.liquify_stroke = Some(stroke);
}
let mut out = json!({ "ids": changed, "skipped": skipped });
if let Some(w) = warning {
out["warning"] = json!(w);
}
Ok(out)
}
// ---------- puppet warp ----------
/// The objects a Puppet Warp command works on: `id`/`ids`, else `default`.
fn puppet_ids(p: &Value, default: impl FnOnce() -> Result<Vec<NodeId>>) -> Result<Vec<NodeId>> {
let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => v,
None => default()?,
};
if ids.is_empty() {
return Err(EngineError::Other("select the artwork to warp".into()));
}
Ok(ids)
}
fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.puppetWarp";
let pins = points_of(p, "pins").ok_or_else(|| bad(C, "pins must be [[x,y]…]"))?;
let moved = points_of(p, "moved").ok_or_else(|| bad(C, "moved must be [[x,y]…]"))?;
if pins.is_empty() || pins.len() != moved.len() {
let rest = bool_or(p, "rest", false);
if (pins.is_empty() && !rest) || pins.len() != moved.len() {
return Err(bad(C, "pins and moved must be non-empty lists of the same length"));
}
if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) {
return Err(bad(C, "pins must be finite"));
}
let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => v,
None => selected_roots(s)?,
};
if ids.is_empty() {
return Err(EngineError::Other("select the artwork to warp".into()));
}
let ids = if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { puppet_ids(p, || selected_roots(s))? };
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let angles: Vec<Option<f64>> = match p.get("angles") {
None | Some(Value::Null) => vec![None; pins.len()],
Some(Value::Array(a)) if a.len() == pins.len() => a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect(),
Some(_) => return Err(bad(C, "angles must be a list as long as pins (degrees or null)")),
};
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
if rest {
let pins: Vec<PuppetPin> = pins.iter().zip(&moved).zip(angles).map(|((r, a), angle)| PuppetPin { rest: *r, at: *a, angle }).collect();
s.edit("Puppet Warp", |d, _| warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e)))?;
return Ok(ids_json);
}
let mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
let pins: Vec<arap::Pin> = pins.iter().zip(&moved).map(|(a, b)| arap::Pin { rest: *a, target: *b }).collect();
let pins: Vec<arap::Pin> = pins.iter().zip(&moved).zip(angles).map(|((a, b), angle)| arap::Pin { angle, ..arap::Pin::new(*a, *b) }).collect();
let deformed = arap::deform(&mesh, &pins);
let f = |q: Point| mesh.map(&deformed, q);
s.edit("Puppet Warp", |d, _| {
@@ -418,7 +668,26 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(ids_json)
}
/// Forget the Puppet Warp pins: not an undo step, and a saved document stays saved (pins are
/// never saved).
pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
let clean = !st.is_dirty();
Arc::make_mut(&mut st.doc).puppet = None;
if clean {
st.mark_saved();
}
}
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
let ids = puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))?;
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let set = PinSet::of(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
let mut v = set.params(&set.pins, expand);
v["auto"] = json!(set.auto);
Ok(v)
}
// ---------- perspective grid ----------
@@ -434,7 +703,7 @@ pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) {
}
/// Change view state of the grid without an undo step.
fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
let st = s.doc_mut()?;
let mut g = grid_of(&st.doc);
f(&mut g);
@@ -447,24 +716,19 @@ fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<Persp
}
fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
let g = grid_of(&s.doc()?.doc).merged(p).map_err(|e| bad("perspective.grid.set", e))?;
s.edit("Define Perspective Grid", |d, _| {
store_grid(d, &g);
Ok(())
})?;
Ok(g.definition_json())
}
fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
let kind = p
.get("kind")
.and_then(Value::as_u64)
.filter(|k| (1..=3).contains(k))
.ok_or_else(|| bad("perspective.grid.preset", "kind must be 1, 2 or 3"))? as u8;
let old = grid_of(&s.doc()?.doc);
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
let g = PerspectiveGrid { attached: old.attached, ..PerspectiveGrid::preset(kind, ab) };
s.edit("Perspective Grid Preset", |d, _| {
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
let reproject = bool_or(p, "reproject", false);
s.edit("Define Perspective Grid", |d, _| {
if reproject {
old.adopt_stored_attachments(d);
for (id, plane, depth) in persp::attached_roots(d) {
let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth));
let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?;
let m = h.then_after(&hi);
warp_checked(d, id, &m)?;
}
}
store_grid(d, &g);
Ok(())
})?;
@@ -502,21 +766,27 @@ fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
}
/// Map `n` with `f`, failing if any point would leave the plane's visible side.
fn warp_checked(d: &mut Document, id: NodeId, f: &dyn Fn(Point) -> Option<Point>) -> Result<()> {
/// Map `n` with `h` (type and symbols in perspective keep their art and are drawn through it),
/// failing if any point would leave the plane's visible side.
fn warp_checked(d: &mut Document, id: NodeId, h: &Homography) -> Result<()> {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let mut pts = vec![];
collect_points(n, &mut pts);
if pts.iter().any(|q| f(*q).is_none()) {
if pts.iter().any(|q| h.apply(*q).is_none()) {
return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into()));
}
warp_node_with(n, &|q| f(q).unwrap_or(q));
project_node(n, h);
Ok(())
}
fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane) -> Result<()> {
/// Attach `id` to `plane`: projected by `map`, else by the map that keeps its bounds' corners
/// (on the nearest gridlines with `snap`).
fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool, map: Option<Homography>) -> Result<()> {
let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?;
let m = g.attach_map(plane, b).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?;
let m =
map.or_else(|| g.attach_homography(plane, b, snap)).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?;
warp_checked(d, id, &m)?;
persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, g.offset(plane));
g.attached.insert(id.0.to_string(), plane);
Ok(())
}
@@ -527,7 +797,7 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
let plane = plane_param(&g, p, "perspective.attach")?;
s.edit("Attach to Active Plane", |d, _| {
for id in &ids {
attach_in(d, &mut g, *id, plane)?;
attach_in(d, &mut g, *id, plane, false, None)?;
}
store_grid(d, &g);
Ok(())
@@ -541,6 +811,9 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Release with Perspective", |d, _| {
for id in &ids {
g.attached.remove(&id.0.to_string());
if let Some(n) = d.node_mut(*id) {
persp::release(n);
}
}
store_grid(d, &g);
Ok(())
@@ -548,30 +821,230 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// One object's share of a perspective edit: the page map for an object on `plane` at `depth`, and
/// the depth it ends at.
type PerspStep<'a> = &'a dyn Fn(&PerspectiveGrid, Plane, f64) -> Result<(Homography, f64)>;
/// Where an object is in perspective: its plane and depth.
type Attached = (Plane, f64);
fn beyond_horizon() -> EngineError {
EngineError::Other("the pointer is beyond the plane's horizon".into())
}
/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay
/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach
/// to `fallback` first. → the edited objects and where the first one was (plane, depth).
fn persp_edit(
s: &mut Session,
label: &str,
ids: &[NodeId],
fallback: Option<Plane>,
copy: bool,
step: PerspStep,
) -> Result<(Vec<NodeId>, Option<Attached>)> {
if ids.is_empty() {
return Err(EngineError::Other("select the objects to transform in perspective".into()));
}
let mut g = grid_of(&s.doc()?.doc);
s.edit(label, |d, sel| {
g.adopt_stored_attachments(d);
let targets = if copy { duplicate_in(d, sel, ids, Affine::IDENTITY)? } else { ids.to_vec() };
let mut first = None;
for id in &targets {
let (plane, depth) = match g.attachment_of(d, *id) {
Some(a) => a,
None => {
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
attach_in(d, &mut g, *id, pl, false, None)?;
(pl, g.offset(pl))
}
};
first.get_or_insert((plane, depth));
let (h, depth) = step(&g, plane, depth)?;
warp_checked(d, *id, &h)?;
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth);
}
store_grid(d, &g);
Ok((targets, first))
})
}
/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once
/// the drag that made it is committed.
fn record_again(s: &mut Session, again: Value) -> Result<()> {
let st = s.doc_mut()?;
match &mut st.interaction {
Some(it) => it.perspective_again = Some(again),
None => st.last_perspective = Some(again),
}
Ok(())
}
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// A plane-space affine map as `perspective.transform`'s `matrix` param.
fn matrix_json(m: Affine) -> Value {
json!(m.as_coeffs())
}
fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.move";
let ids = roots(s, p)?;
let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?;
let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?;
let mut g = grid_of(&s.doc()?.doc);
let fallback = plane_param(&g, p, C).ok();
s.edit("Move in Perspective", |d, _| {
for id in &ids {
let plane = match g.attached_plane(*id) {
Some(pl) => pl,
None => {
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
attach_in(d, &mut g, *id, pl)?;
pl
}
};
let m = g.move_map(plane, from, to).ok_or_else(|| EngineError::Other("the pointer is beyond the plane's horizon".into()))?;
warp_checked(d, *id, &m)?;
if ![from.x, from.y, to.x, to.y].iter().all(|v| v.is_finite()) {
return Err(bad(C, "from and to must be finite"));
}
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let (perp, copy) = (bool_or(p, "perpendicular", false), bool_or(p, "copy", false));
// Snap to Grid: a correction for the objects on the first one's plane and depth.
let fix = if perp { None } else { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) };
let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| {
dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2)
};
// In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches.
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
if perp {
let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?;
Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth))
} else {
let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?);
Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth))
}
};
let label = if copy { "Copy in Perspective" } else { "Move in Perspective" };
let (targets, first) = persp_edit(s, label, &ids, fallback, copy, &step)?;
// Transform Again repeats the first object's move.
if let Some((pl, depth)) = first {
let g = grid_of(&s.doc()?.doc);
let again = match perp {
true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})),
false => {
g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy}))
}
};
if let Some(a) = again {
record_again(s, a)?;
}
}
Ok(ids_json(&targets))
}
fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.transform";
let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?;
let dz = f64_or(p, "depth", 0.0);
if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() {
return Err(bad(C, "matrix must be finite and invertible, depth finite"));
}
let ids = roots(s, p)?;
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let copy = bool_or(p, "copy", false);
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
let to = depth + dz;
if to.abs() > persp::MAX_DEPTH {
return Err(EngineError::Other("the objects would leave the grid".into()));
}
Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to))
};
let (targets, _) = persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?;
record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?;
Ok(ids_json(&targets))
}
/// Object › Transform › Transform Again after a perspective move or scale: the same plane-space
/// transform on the selection.
pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result<Value> {
let mut p = again.clone();
if let Some(o) = p.as_object_mut() {
o.remove("ids");
}
persp_transform(s, &p)
}
fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.plane.move";
let old = grid_of(&s.doc()?.doc);
let plane = plane_param(&old, p, C)?;
let from = old.offset(plane);
let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) {
(Some(o), _) => o,
(None, Some(b)) => from + b,
(None, None) => return Err(bad(C, "give offset or by")),
};
let objects = str_param(p, "objects").unwrap_or("none");
if !matches!(objects, "none" | "move" | "copy") {
return Err(bad(C, "objects must be none, move or copy"));
}
let mut g = old.clone();
g.set_offset(plane, to);
g.validate().map_err(|e| bad(C, e))?;
let label = match objects {
"copy" => "Move Plane and Copy Objects",
"move" => "Move Plane and Objects",
_ => "Move Plane",
};
let ids = s.edit(label, |d, sel| {
old.adopt_stored_attachments(d);
let mut ids = vec![];
if objects != "none" {
// The objects on the plane where it was (not those moved off it along its normal).
let on: Vec<NodeId> = persp::attached_roots(d)
.into_iter()
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
.map(|(id, ..)| id)
.collect();
ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on };
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
for id in &ids {
warp_checked(d, *id, &m)?;
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to);
}
}
store_grid(d, &g);
Ok(ids)
})?;
Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = id_param(p, "id")
.or_else(|| st.selection.objects.first().copied())
.ok_or_else(|| EngineError::Other("select an object in perspective".into()))?;
let mut g = grid_of(&st.doc);
let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?;
g.set_offset(plane, depth);
g.plane = plane;
g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?;
s.edit("Move Plane to Match Object", |d, _| {
store_grid(d, &g);
Ok(())
})?;
ok()
Ok(json!({ "plane": plane.id(), "offset": depth }))
}
fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.nudge";
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) {
return Err(bad(C, "dx and dy give the arrow's direction"));
}
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let ids = selected_roots(s)?;
// The step is measured on the page at the centre of the selection's perspective box.
let st = s.doc()?;
let g = grid_of(&st.doc);
let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?;
let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?;
let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k;
persp_move(
s,
&json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": bool_or(p, "copy", false)}),
)
}
fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
@@ -580,11 +1053,17 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, C)?;
let snap = bool_or(p, "snap", g.snap);
// A click-to-size dialog's shape keeps its sizes in plane units from the click.
let map = match point_param(p, "at") {
Some(at) => Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?),
None => None,
};
let undo_before = s.doc()?.history.undo.len();
let r = s.execute(&command, &params)?;
let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?;
let res = s.edit("Draw in Perspective", |d, _| {
attach_in(d, &mut g, id, plane)?;
attach_in(d, &mut g, id, plane, snap, map)?;
store_grid(d, &g);
Ok(())
});
@@ -602,10 +1081,85 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
/// Shape tool previews become `perspective.draw` while the grid is shown with an active plane.
pub(crate) fn perspective_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> {
const SHAPES: &[&str] = &["shape.rectangle", "shape.ellipse", "shape.polygon", "shape.star", "shape.line", "shape.rectangularGrid", "shape.arc"];
const SHAPES: &[&str] = &[
"shape.rectangle",
"shape.ellipse",
"shape.polygon",
"shape.star",
"shape.line",
"shape.rectangularGrid",
"shape.arc",
"shape.flare",
"shape.spiral",
"shape.polarGrid",
];
if !SHAPES.contains(&cmd) {
return None;
}
let g = PerspectiveGrid::from_doc(&s.active()?.doc)?;
let g = PerspectiveGrid::current(&s.active()?.doc);
(g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params})))
}
/// A click-to-size shape dialog's command (`cmd` with `params`, clicked at `at`) as it runs: on the
/// active plane while the grid shows (`perspective.draw` keeping the sizes in plane units from the
/// click), else `None` (run it as it is).
pub fn perspective_click(s: &Session, cmd: &str, params: &Value, at: Point) -> Option<(String, Value)> {
let (c, mut p) = perspective_rewrite(s, cmd, params)?;
p["at"] = json!([at.x, at.y]);
Some((c, p))
}
/// Object › Perspective › Edit Text: type in perspective shown flat where it is drawn, in isolation
/// mode, to be edited; exiting isolation projects it again ([`finish_edit_text`]).
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id =
id_param(p, "id").or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
if !st.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) {
return Err(EngineError::Other("select type in perspective".into()));
}
s.edit("Edit Text", |d, _| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let h = n.projection().ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("select type in perspective".into())) };
// Shown flat where it is drawn: the type moves there and the projection moves the other
// way, so the picture stays.
let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) };
let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center());
t.transform(shift);
if let Some(rec) = n.perspective.as_deref_mut() {
rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array());
rec.editing = true;
}
Ok(())
})?;
let st = s.doc_mut()?;
st.isolation = Some(id);
st.selection.set([id]);
st.revision += 1;
Ok(json!({ "id": id.0 }))
}
/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and
/// in every undo state (the flat view is never an undo step of its own).
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
let clear = |doc: &mut Arc<Document>| {
if doc.node(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing)
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut())
{
p.editing = false;
}
};
let saved = Arc::ptr_eq(&st.doc, &st.saved_doc);
clear(&mut st.doc);
if saved {
st.saved_doc = st.doc.clone();
}
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
clear(&mut e.doc);
}
if let Some(it) = &mut st.interaction {
clear(&mut it.doc);
}
st.revision += 1;
}
+22 -2
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (type → outlines, live shapes → paths, effects baked), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, envelopes → their distorted content), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
has_selection,
expand
),
@@ -84,6 +84,10 @@ struct Expandable {
effects: bool,
fill: bool,
stroke: bool,
/// An envelope (Object expands it into its distorted content).
envelope: bool,
/// Live blends (Object expands them into groups of their steps).
blends: bool,
}
impl Expandable {
@@ -95,6 +99,8 @@ impl Expandable {
e.text |= matches!(c.kind, NodeKind::Text(_));
e.live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. });
e.effects |= !c.appearance.effects.is_empty();
e.envelope |= matches!(c.kind, NodeKind::Envelope { .. });
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
e.fill |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
@@ -106,7 +112,7 @@ impl Expandable {
}
fn object(&self) -> bool {
self.text || self.live || self.effects
self.text || self.live || self.effects || self.envelope || self.blends
}
}
@@ -129,9 +135,23 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let from = s.doc()?.history.undo.len();
let rev0 = s.doc()?.revision;
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
if object && e.blends {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| super::live::expand_blends(d, &roots).map(|_| ()))?;
}
if object && e.effects {
let _ = run_raw(s, "effect.expandAppearance", &json!({}));
}
if object && e.envelope {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| {
for r in &roots {
super::live::expand_envelopes_under(d, *r)?;
}
Ok(())
})?;
}
if object && e.live {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| {
File diff suppressed because it is too large. Load diff
+2
View File
@@ -46,6 +46,7 @@ mod path;
mod pathops;
mod patterncmds;
pub mod pdfcmds;
pub mod perspgrid;
mod place;
pub mod plugin;
pub mod prefscmds;
@@ -250,6 +251,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(plugin::specs());
v.extend(cut::specs());
v.extend(halftone::specs());
v.extend(perspgrid::specs());
v
})
}
+5
View File
@@ -219,6 +219,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
let st = s.doc_mut()?;
if st.interaction.is_none() {
st.last_transform = Some((xf, copy));
st.last_perspective = None;
}
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
@@ -299,6 +300,9 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
}
fn transform_again(s: &mut Session, _: &Value) -> Result<Value> {
if let Some(again) = s.doc()?.last_perspective.clone() {
return super::distortcmds::transform_again(s, &again);
}
let (m, copy) = s.doc()?.last_transform.ok_or_else(|| EngineError::Other("no previous transform".into()))?;
let ids = selected_roots(s)?;
apply_transform(s, "Transform Again", ids, m, &json!({ "copy": copy }))
@@ -661,6 +665,7 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
if let Some(i) = st.isolation.take() {
super::distortcmds::finish_edit_text(st, i);
st.selection.set([i]);
}
st.revision += 1;
+1 -14
View File
@@ -197,20 +197,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
if independent {
a.kind = AnchorKind::Corner;
}
let (moved, other) = if out { (&mut a.h_out, &mut a.h_in) } else { (&mut a.h_in, &mut a.h_out) };
*moved = pos;
if a.kind == AnchorKind::Smooth {
// Keep the opposite handle collinear, preserving its length.
let len = (*other - a.p).hypot();
let dir = a.p - pos;
let l = dir.hypot();
if l > 1e-9 {
*other = a.p + dir * (len / l);
}
}
a.set_handle(out, pos, independent);
Ok(())
})?;
ok()
+481
View File
@@ -0,0 +1,481 @@
//! View → Perspective Grid → Define Grid: `perspective.grid.get` reads the grid (the model, the
//! Define Grid fields and the station point) and `perspective.grid.define` sets it from those
//! fields. The model and the camera maths live in `vectorcraft_tools::distort::perspective`.
//!
//! Presets: the built-in views (generated in code, fitted to the first artboard, protected) and
//! the user's, saved with the preferences ([`crate::Prefs::perspective_presets`]) as Define Grid
//! fields; `perspective.grid.preset` applies one, `perspective.presets.*` manage them.
//!
//! View options (Lock Grid, Lock Station Point, Snap to Grid, Show Rulers) are view state on
//! the grid: they toggle without an undo step, as Show Grid does.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::{Point, Rect, Vec2};
use vectorcraft_tools::distort::perspective::Plane;
use vectorcraft_tools::distort::perspective::define::{BUILTINS, LETTER, first_artboard, is_builtin};
use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace};
use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid};
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
use super::distortcmds::{grid_of, silent, store_grid};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "perspective.grid.get",
"Perspective Grid",
[],
None,
"{} → {grid: the model (as perspective.grid.set takes it), define: the Define Grid fields (as perspective.grid.define takes them), station: {x, y, distance} (the viewer: centre of vision on the horizon, distance to the picture plane, pt), defined: false while the document uses the default grid}",
has_doc,
grid_get
),
cmd!(
"perspective.grid.define",
"Define Perspective Grid",
[],
None,
"{name?: preset name (\"\" = custom), kind?: 1|2|3, units?: unit name (points, pixels, inches, centimeters, millimeters…), scale?: [artboard, real world], gridline?: gridline every, angle?: viewing angle (0–90°), distance?: viewing distance, horizonHeight?: above the ground level, thirdVp?: [x right, y up] from the centre of vision (3-point), leftColor?, rightColor?, groundColor?: \"#rrggbb\", opacity?: 0–100} lengths are real-world lengths in `units` at `scale`; missing fields keep the grid's (see perspective.grid.get define). Only what changes changes: a new type, angle or distance moves the vanishing points around the station point. One undo step → the new define fields",
has_doc,
grid_define
),
cmd!(
query "perspective.presets.list",
"Perspective Grid Presets",
[],
None,
"{} → {presets: [{builtIn, name, kind, …the perspective.grid.define fields}]} the built-in views (fitted to the first artboard, protected) first, then the saved presets",
always,
presets_list
),
cmd!(
query "perspective.presets.save",
"Save Perspective Grid Preset",
[],
None,
"{name?: (default: a new \"Perspective Preset N\"), newName?: rename it, preset?: the preset to start from (default: the saved preset `name`, else the document's grid), …the perspective.grid.define fields (over it)} create or change a saved preset (Save Grid as Preset: just `name`); built-in ones are protected → {name, created, definition}",
always,
presets_save
),
cmd!(
query "perspective.presets.delete",
"Delete Perspective Grid Preset",
[],
None,
"{name} delete a saved preset (built-in ones stay) → {deleted: name}",
always,
presets_delete
),
cmd!(
query "perspective.presets.export",
"Export Perspective Grid Presets",
[],
None,
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcperspective file (JSON) → {path, count}; without path → {data: the file's text, count}",
always,
presets_export
),
cmd!(
query "perspective.presets.import",
"Import Perspective Grid Presets",
[],
None,
"{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else imported ones whose name is taken get a number)} add the presets of a .vcperspective file (as perspective.presets.export writes) to the saved ones → {imported: [names]}",
always,
presets_import
),
cmd!(
query "perspective.widget.options",
"Perspective Grid Options",
[],
None,
"{show?: bool, position?: topLeft|topRight|bottomLeft|bottomRight} the Plane Switching Widget: shown or not, and the corner of the document window it stays in (kept with the preferences; no params reads them) → {show, position}",
always,
widget_options
),
cmd!(
"perspective.grid.lock",
"Lock Grid",
[],
None,
"{on?: bool} (no param toggles) View › Perspective Grid › Lock Grid: the grid's widgets can't be dragged (the Plane Switching Widget still works). View state: not an undo step → {on}",
has_doc,
lock
),
cmd!(
"perspective.grid.lockStation",
"Lock Station Point",
[],
None,
"{on?: bool} (no param toggles) dragging one vanishing point moves the other around the station point (the viewing angle turns, the viewer stays). View state: not an undo step → {on}",
has_doc,
lock_station
),
cmd!(
"perspective.grid.snap",
"Snap to Grid",
[],
None,
"{on?: bool} (no param toggles) art drawn or moved in perspective lands on gridlines within a quarter cell (on by default). View state: not an undo step → {on}",
has_doc,
snap
),
cmd!(
"perspective.grid.rulers",
"Show Rulers",
[],
None,
"{on?: bool} (no param toggles) a ruler up the line where the planes meet, in the grid's units at its scale. View state: not an undo step → {on}",
has_doc,
rulers
),
]
}
/// The `format` of a perspective grid presets file, also its extension.
pub const PRESET_FORMAT: &str = "vcperspective";
/// The extensions of perspective grid presets files (opening one imports it).
pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT];
/// The most presets one file imports.
const MAX_IMPORT: usize = 1000;
/// What `perspective.presets.export` writes and `perspective.presets.import` reads.
#[derive(Serialize, Deserialize)]
struct PresetFile {
format: String,
#[serde(default)]
presets: Vec<Value>,
}
impl Session {
/// The artboard presets are fitted to: the active document's first (a Letter page without).
fn preset_artboard(&self) -> Rect {
self.active().map_or(LETTER, |d| first_artboard(&d.doc))
}
/// Every perspective grid preset: the built-in views, then the saved ones.
pub fn perspective_presets(&self) -> Vec<GridDefinition> {
let ab = self.preset_artboard();
let builtins = BUILTINS.iter().filter_map(|b| PerspectiveGrid::builtin(b.0, ab)).map(|g| g.definition());
builtins.chain(self.prefs.perspective_presets.iter().cloned()).collect()
}
/// The preset `name` (any case): a built-in view or a saved one.
pub fn perspective_preset(&self, name: &str) -> Option<GridDefinition> {
match PerspectiveGrid::builtin(name, self.preset_artboard()) {
Some(g) => Some(g.definition()),
None => self.persp_saved_index(name).and_then(|i| self.prefs.perspective_presets.get(i)).cloned(),
}
}
/// The grid preset `name` makes on `ab`: a built-in view, or a saved preset's fields over the
/// normal view of its type.
pub fn perspective_preset_grid(&self, name: &str, ab: Rect) -> Option<PerspectiveGrid> {
if let Some(g) = PerspectiveGrid::builtin(name, ab) {
return Some(g);
}
let def = self.perspective_preset(name)?;
PerspectiveGrid::normal(def.kind, ab).with_definition(&def).ok()
}
/// The first free "Perspective Preset N": the name a new preset gets.
pub fn new_perspective_preset_name(&self) -> String {
(1..).map(|i| format!("Perspective Preset {i}")).find(|n| !self.persp_preset_taken(n, None)).unwrap_or_default()
}
/// The index of the saved preset `name` (any case).
fn persp_saved_index(&self, name: &str) -> Option<usize> {
self.prefs.perspective_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim()))
}
/// Whether `name` is a built-in view's or a saved preset's other than the one at `except`.
fn persp_preset_taken(&self, name: &str, except: Option<usize>) -> bool {
is_builtin(name) || self.persp_saved_index(name).is_some_and(|i| Some(i) != except)
}
/// The name of the first preset among `candidates` whose fields `def` has ("" for none).
fn preset_named(&self, def: &GridDefinition, candidates: &[&str]) -> String {
candidates
.iter()
.filter(|n| !n.is_empty())
.find_map(|n| self.perspective_preset(n).filter(|p| p.same(def)))
.map(|p| p.name)
.unwrap_or_default()
}
}
/// What `perspective.grid.get` reports for `g`.
pub(crate) fn grid_info(doc: &vectorcraft_doc::Document, g: &PerspectiveGrid) -> Value {
let st = g.station();
json!({
"grid": g.definition_json(),
"define": g.definition(),
"station": {"x": st.x, "y": g.horizon, "distance": st.distance},
"defined": PerspectiveGrid::from_doc(doc).is_some(),
})
}
fn grid_get(s: &mut Session, _: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
Ok(grid_info(doc, &grid_of(doc)))
}
fn grid_define(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.grid.define";
let old = grid_of(&s.doc()?.doc);
let def: GridDefinition = old.definition().merged(p).map_err(|e| bad(C, e))?;
let mut g = old.with_definition(&def).map_err(|e| bad(C, e))?;
// The grid is the preset it was given or came from while it has that preset's fields.
g.name = s.preset_named(&g.definition(), &[&def.name, &old.name]);
if g != old {
s.edit("Define Perspective Grid", |d, _| {
store_grid(d, &g);
Ok(())
})?;
}
Ok(json!(g.definition()))
}
/// `perspective.grid.preset`: reset the grid to a preset fitted to the first artboard (shown;
/// the attached objects stay attached).
pub(crate) fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.grid.preset";
let name = match (str_param(p, "name"), p.get("kind").and_then(Value::as_u64)) {
(Some(n), _) => n.to_string(),
(None, Some(k @ 1..=3)) => PerspectiveGrid::normal(k as u8, LETTER).name,
_ => return Err(bad(C, "kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list)")),
};
let old = grid_of(&s.doc()?.doc);
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
let g = s.perspective_preset_grid(&name, ab).ok_or_else(|| bad(C, format!("no perspective grid preset named `{name}`")))?;
// The objects stay attached and the view options stay as they were.
let g = PerspectiveGrid { attached: old.attached, locked: old.locked, lock_station: old.lock_station, snap: old.snap, rulers: old.rulers, ..g };
s.edit("Perspective Grid Preset", |d, _| {
store_grid(d, &g);
Ok(())
})?;
Ok(g.definition_json())
}
fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
let rows: Vec<Value> = s
.perspective_presets()
.into_iter()
.map(|d| {
let mut v = json!(d);
v["builtIn"] = json!(is_builtin(&d.name));
v
})
.collect();
Ok(json!({ "presets": rows }))
}
fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.presets.save";
let name = match str_param(p, "name").map(str::trim) {
Some("") => return Err(bad(C, "`name` is empty")),
Some(n) => n.to_string(),
None => s.new_perspective_preset_name(),
};
if is_builtin(&name) {
return Err(bad(C, format!("`{name}` is a built-in preset and can't change: save it under another name")));
}
let at = s.persp_saved_index(&name);
let saved = at.and_then(|i| s.prefs.perspective_presets.get(i)).cloned();
// A saved preset changes from its own fields unless `preset` says where to start.
let base = match (str_param(p, "preset"), &saved) {
(Some(b), _) => {
s.perspective_preset(b).ok_or_else(|| bad(C, format!("no perspective grid preset named `{b}` (see perspective.presets.list)")))?
}
(None, Some(old)) => old.clone(),
(None, None) => s.active().map(|d| grid_of(&d.doc).definition()).unwrap_or_default(),
};
let mut def = base.merged(p).map_err(|e| bad(C, e))?;
def.name = match str_param(p, "newName").map(str::trim) {
Some("") => return Err(bad(C, "`newName` is empty")),
Some(n) if s.persp_preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))),
Some(n) => n.to_string(),
None => saved.map_or(name, |old| old.name),
};
let r = json!({ "name": def.name, "created": at.is_none(), "definition": def });
match at.and_then(|i| s.prefs.perspective_presets.get_mut(i)) {
Some(slot) => *slot = def,
None => s.prefs.perspective_presets.push(def),
}
Ok(r)
}
fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.presets.delete";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
if is_builtin(name) {
return Err(bad(C, format!("`{name}` is a built-in preset and stays")));
}
let i = s.persp_saved_index(name).ok_or_else(|| bad(C, format!("no saved preset named `{name}`")))?;
Ok(json!({ "deleted": s.prefs.perspective_presets.remove(i).name }))
}
fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.presets.export";
let presets = match p.get("names").and_then(Value::as_array) {
Some(names) => {
let find = |n: &Value| {
let n = n.as_str().unwrap_or_default();
s.perspective_preset(n).ok_or_else(|| bad(C, format!("no perspective grid preset named `{n}`")))
};
names.iter().map(find).collect::<Result<Vec<_>>>()?
}
None => s.prefs.perspective_presets.clone(),
};
if presets.is_empty() {
return Err(bad(C, "no saved presets to export (name built-in ones in `names`)"));
}
let count = presets.len();
let presets = presets.iter().map(|d| json!(d)).collect();
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?;
match str_param(p, "path") {
Some(path) => {
super::fileio::write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "count": count}))
}
None => Ok(json!({"data": text, "count": count})),
}
}
fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.presets.import";
let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
(Some(path), ..) => super::fileio::read_file(path)?,
(None, Some(text), _) => text.as_bytes().to_vec(),
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?,
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
};
let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a perspective grid presets file: {e}")))?;
if file.format != PRESET_FORMAT {
return Err(bad(C, format!("not a perspective grid presets file (format `{}`)", file.format)));
}
if file.presets.len() > MAX_IMPORT {
return Err(bad(C, format!("more than {MAX_IMPORT} presets")));
}
// Every preset is checked as Define Grid checks its fields before any is added.
let base = GridDefinition::default();
let check = |v: &Value| base.merged(v).map_err(|e| bad(C, format!("`{}`: {e}", v["name"].as_str().unwrap_or("?"))));
let presets = file.presets.iter().map(check).collect::<Result<Vec<_>>>()?;
let replace = bool_or(p, "replace", false);
let mut imported = vec![];
for mut preset in presets {
// A built-in view comes in as a saved copy ("2P-Normal View"…): the built-in one stays.
let name = preset.name.trim().trim_start_matches('[').trim_end_matches(']').trim();
let base = Some(name).filter(|n| !n.is_empty()).unwrap_or("Perspective Preset").to_string();
match s.persp_saved_index(&base).filter(|_| replace) {
Some(i) => {
if let Some(slot) = s.prefs.perspective_presets.get_mut(i) {
preset.name = slot.name.clone();
imported.push(preset.name.clone());
*slot = preset;
}
}
None => {
preset.name = unique_name(&base, |n| s.persp_preset_taken(n, None));
imported.push(preset.name.clone());
s.prefs.perspective_presets.push(preset);
}
}
}
Ok(json!({ "imported": imported }))
}
/// Toggle (or set, with `on`) a view option of the grid.
fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
let want = p.get("on").and_then(Value::as_bool);
let mut g = silent(s, |g| {
let f = field(g);
*f = want.unwrap_or(!*f);
})?;
Ok(json!({ "on": *field(&mut g) }))
}
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.locked)
}
fn lock_station(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.lock_station)
}
fn snap(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.snap)
}
fn rulers(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.rulers)
}
/// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`):
/// the plane and depth of the first one and the plane-space correction that lands the edge of the
/// joint bounds of the objects there nearer a gridline on it.
pub(crate) fn snap_fix(doc: &Document, ids: &[NodeId], p: &Value, (from, to): (Point, Point)) -> Option<(Plane, f64, Vec2)> {
let g = grid_of(doc);
if !bool_or(p, "snap", g.snap) {
return None;
}
let (plane, depth, b) = g.plane_bounds(doc, ids)?;
let dv = g.plane_delta(plane, depth, from, to)?;
Some((plane, depth, g.snap_offset(b, dv) - dv))
}
fn widget_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.widget.options";
let mut o = s.prefs.perspective_widget;
if let Some(v) = p.get("show") {
o.show = v.as_bool().ok_or_else(|| bad(C, "show must be true or false"))?;
}
if let Some(v) = p.get("position") {
o.position =
v.as_str().and_then(WidgetCorner::parse).ok_or_else(|| bad(C, "position must be topLeft, topRight, bottomLeft or bottomRight"))?;
}
s.prefs.perspective_widget = o;
Ok(json!(o))
}
impl Session {
/// The Plane Switching Widget's part of a pointer event: a press on it picks the plane (its
/// drag and release go nowhere); None when the event isn't the widget's.
pub(crate) fn plane_widget_pointer(&mut self, ev: &PointerEvent, view: crate::ViewInfo) -> Option<Result<Vec<crate::UiRequest>>> {
match ev.kind {
PointerKind::Down => {
let w = self.prefs.perspective_widget;
let st = self.active().filter(|_| w.show)?;
let g = PerspectiveGrid::current(&st.doc);
if !g.shown(self.tool_id()) {
return None;
}
let place = WidgetPlace { screen: view.screen.as_ref(), corner: w.position };
let plane = g.widget_hit_at(&st.doc, 1.0 / view.zoom.max(1e-9), place, ev.pos)?;
self.plane_widget_press = true;
Some(self.execute("perspective.plane.set", &json!({ "plane": plane.id() })).map(|_| vec![]))
}
PointerKind::Drag if self.plane_widget_press => Some(Ok(vec![])),
PointerKind::Up if std::mem::take(&mut self.plane_widget_press) => Some(Ok(vec![])),
_ => None,
}
}
/// The plane digit key `key` picks while the grid is shown: 1 left, 2 horizontal (ground),
/// 3 right, 4 none.
pub(crate) fn plane_key(&self, key: ToolKey) -> Option<Plane> {
let plane = match key {
ToolKey::Digit(1) => Plane::Left,
ToolKey::Digit(2) => Plane::Ground,
ToolKey::Digit(3) => Plane::Right,
ToolKey::Digit(4) => Plane::None,
_ => return None,
};
PerspectiveGrid::current(&self.active()?.doc).shown(self.tool_id()).then_some(plane)
}
}
+12 -2
View File
@@ -329,10 +329,11 @@ pub fn spec(key: &str) -> Option<&'static PrefSpec> {
}
/// Preferences kept as one object with a command of their own instead of [`PREF_SPECS`] rows (they
/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`). `prefs.get`
/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`) and the
/// Perspective Grid Options (`perspective.widget.options`). `prefs.get`
/// and `prefs.set` take them by key (a partial object updates what it names) and `prefs.reset`
/// without a category resets them.
pub const PREF_GROUPS: &[&str] = &["eyedropper"];
pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget"];
/// Validate a value for preference group `key` against `current`.
fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result<Value, String> {
@@ -341,6 +342,15 @@ fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result<
let cur: super::EyedropperOptions = serde_json::from_value(current.clone()).unwrap_or_default();
cur.merged(v).map(|o| json!(o))
}
"perspectiveWidget" => {
let mut merged = current.clone();
if let (Some(o), Some(p)) = (merged.as_object_mut(), v.as_object()) {
o.extend(p.clone());
}
serde_json::from_value::<vectorcraft_tools::distort::perspective::widget::WidgetOptions>(merged)
.map(|o| json!(o))
.map_err(|e| e.to_string())
}
_ => Err(format!("unknown preference `{key}`")),
}
}
+3 -1
View File
@@ -273,7 +273,9 @@ pub(crate) fn put_below(d: &mut Document, m: &mut Node, image: Node) {
let mut inner = m.clone();
inner.id = d.alloc_id();
(inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None);
inner.knockout = Default::default();
// Knockout is between the object's own parts (a blend's steps): it stays with them,
// so the image doesn't knock out the art over it.
m.knockout = Default::default();
inner.knockout_shape = false;
m.appearance = Default::default();
m.kind = NodeKind::Group { children: vec![Arc::new(image), Arc::new(inner)], clip: false };
+83 -1
View File
@@ -22,6 +22,7 @@ use vectorcraft_tools::{PaintDefaults, Tool};
pub use cmd::EyedropperOptions;
pub use cmd::clipboard::Clipboard;
pub use cmd::distortcmds::perspective_click;
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use tooling::{UiRequest, ViewInfo};
@@ -81,6 +82,9 @@ pub struct Interaction {
/// Per-document state restored on cancel (current layer, isolation).
pub active_layer: Option<NodeId>,
pub isolation: Option<NodeId>,
/// The perspective transform the previews make (`perspective.transform` params): Transform
/// Again repeats it once the drag is committed.
pub perspective_again: Option<Value>,
}
/// Per-document editing state.
@@ -131,6 +135,9 @@ pub struct DocState {
pub recovery: Option<cmd::recovery::RecoveryCopy>,
/// View → Show Print Tiling, per document (view state: not saved, not undoable).
pub print_tiling: bool,
/// Transform Again after a perspective move or scale (Perspective Selection tool): the
/// `perspective.transform` params it repeats. `None` once an ordinary transform follows.
pub last_perspective: Option<Value>,
}
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -163,6 +170,7 @@ impl DocState {
recovered: false,
recovery: None,
print_tiling: false,
last_perspective: None,
}
}
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
@@ -476,6 +484,26 @@ pub struct Prefs {
/// the Properties panel and the Control bar (their size fields pass `proportional` to
/// `object.setBounds`).
pub constrain_proportions: bool,
/// The tools' persistent options by store (a tool id, or a store a family shares: `liquify`
/// holds the Liquify tools' Global Brush Dimensions), see [`vectorcraft_tools::settings`]:
/// kept across tool switches and saved with the preferences. Not a Preferences dialog field:
/// resetting the preferences keeps them; `tool.setOption` edits them.
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub tool_settings: std::collections::BTreeMap<String, serde_json::Map<String, Value>>,
/// View → Perspective Grid presets: the user's (the built-in ones aren't stored). A local
/// library, not a Preferences dialog field: resetting the preferences keeps it;
/// `perspective.presets.*` edit it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub perspective_presets: Vec<vectorcraft_tools::distort::perspective::GridDefinition>,
/// Blend Options set with no blend selected: what new blends start with
/// (`object.blend.options`; none: Smooth Color, Align to Page). A tool setting, not a
/// Preferences dialog field: it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting the
/// preferences keeps it.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_options: Option<vectorcraft_doc::live::BlendDefaults>,
/// Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane
/// Switching Widget shows and where (`perspective.widget.options`).
pub perspective_widget: vectorcraft_tools::distort::perspective::widget::WidgetOptions,
}
impl Default for Prefs {
@@ -609,6 +637,10 @@ impl Default for Prefs {
web_export_settings: None,
print_presets: vec![],
constrain_proportions: false,
tool_settings: Default::default(),
perspective_presets: vec![],
blend_options: None,
perspective_widget: Default::default(),
}
}
}
@@ -676,6 +708,14 @@ pub struct Session {
/// While the active tool's actions run: their commands aren't "a command from outside the
/// tool" ([`Session::after_command`]).
pub(crate) in_tool_actions: bool,
/// Envelope Options with no envelope selected: the options and fidelity new envelopes get
/// (`None`: the reference app's defaults, fidelity 50); not saved.
pub(crate) envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>,
/// The Liquify stroke the last live preview applied, which the next sample of the drag goes on
/// from ([`cmd::distortcmds::LiquifyStroke`]).
pub(crate) liquify_stroke: Option<Box<cmd::distortcmds::LiquifyStroke>>,
/// A press on the Plane Switching Widget is under way: its drag and release are the widget's.
pub(crate) plane_widget_press: bool,
}
impl Default for Session {
@@ -717,6 +757,9 @@ impl Session {
batch_stash: None,
recovery: Default::default(),
in_tool_actions: false,
envelope_defaults: None,
liquify_stroke: None,
plane_widget_press: false,
}
}
@@ -756,7 +799,8 @@ impl Session {
if self.batch_stash.is_none() {
let _ = self.cancel_interaction();
}
self.tool = vectorcraft_tools::create(self.tool.id());
self.keep_tool_settings();
self.tool = self.make_tool(self.tool.id());
}
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.docs.len() {
@@ -1017,6 +1061,10 @@ impl Session {
f(&st.doc, &mut st.selection);
st.selection.prune(&st.doc);
st.revision += 1;
// Puppet Warp pins belong to the art they were placed on: another selection starts afresh.
if st.doc.puppet.as_ref().is_some_and(|p| p.ids != st.selection.objects) {
cmd::distortcmds::drop_puppet_pins(st);
}
Ok(())
}
@@ -1034,6 +1082,7 @@ impl Session {
preview: None,
active_layer: st.active_layer,
isolation: st.isolation,
perspective_again: None,
});
Ok(())
}
@@ -1062,14 +1111,19 @@ impl Session {
let st = self.doc_mut()?;
let Some(mut it) = st.interaction.take() else { return Ok(()) };
let Some(preview) = it.preview.take() else { return Ok(()) };
let perspective_again = it.perspective_again.take();
st.keep_interaction(it);
if preview.0 == "object.transform" {
let m = cmd::matrix_param(&preview.1, "matrix");
let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
if let Some(m) = m {
st.last_transform = Some((m, copy));
st.last_perspective = None;
}
}
if perspective_again.is_some() {
st.last_perspective = perspective_again;
}
self.journal.push(preview);
Ok(())
}
@@ -1110,6 +1164,12 @@ mod tests_attributes;
#[cfg(test)]
mod tests_bboxrotate;
#[cfg(test)]
mod tests_blendfidelity;
#[cfg(test)]
mod tests_blendopts;
#[cfg(test)]
mod tests_blendspine;
#[cfg(test)]
mod tests_brushsym;
#[cfg(test)]
mod tests_build;
@@ -1156,6 +1216,12 @@ mod tests_effectedit;
#[cfg(test)]
mod tests_emptytype;
#[cfg(test)]
mod tests_envelope;
#[cfg(test)]
mod tests_envelope_distort;
#[cfg(test)]
mod tests_envelope_edit;
#[cfg(test)]
mod tests_expand;
#[cfg(test)]
mod tests_eyedropper;
@@ -1196,6 +1262,8 @@ mod tests_links;
#[cfg(test)]
mod tests_linkspanel;
#[cfg(test)]
mod tests_liquify;
#[cfg(test)]
mod tests_live;
#[cfg(test)]
mod tests_maskview;
@@ -1234,6 +1302,14 @@ mod tests_pdfpresets;
#[cfg(test)]
mod tests_pdfraster;
#[cfg(test)]
mod tests_persp_planes;
#[cfg(test)]
mod tests_persp_select;
#[cfg(test)]
mod tests_persp_text;
#[cfg(test)]
mod tests_perspgrid;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_plugins;
@@ -1256,6 +1332,8 @@ mod tests_printtiling;
#[cfg(test)]
mod tests_proxyitems;
#[cfg(test)]
mod tests_puppetwarp;
#[cfg(test)]
mod tests_rastersettings;
#[cfg(test)]
mod tests_recolor;
@@ -1302,6 +1380,8 @@ mod tests_tileedge;
#[cfg(test)]
mod tests_tints;
#[cfg(test)]
mod tests_toolsettings;
#[cfg(test)]
mod tests_transparencygrid;
#[cfg(test)]
mod tests_units;
@@ -1312,4 +1392,6 @@ mod tests_widthpoints;
#[cfg(test)]
mod tests_widthprofiles;
#[cfg(test)]
mod tests_widthtool;
#[cfg(test)]
mod tests_xform;
+206
View File
@@ -0,0 +1,206 @@
//! Blend fidelity (M8.20): start points that don't twist, strokes, groups, compound paths, text
//! and symbols interpolating, knockout on the canvas, and Expand / Release keeping what the
//! blend carries.
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Knockout, Node, NodeKind};
use vectorcraft_geom::{Point, Rect};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn steps(s: &Session, g: NodeId) -> Vec<Node> {
vectorcraft_doc::live::expand_live(&node(s, g))
}
fn make(s: &mut Session, ids: &[NodeId], steps: u32) -> NodeId {
id_of(&s.execute("object.blend.make", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "steps": steps})).unwrap())
}
#[test]
fn closed_shapes_start_where_they_dont_twist() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
// The same square drawn from its bottom right corner.
let b = id_of(&s.execute("path.create", &json!({"d": "M240 40 L200 40 L200 0 L240 0 Z"})).unwrap());
let g = make(&mut s, &[a, b], 1);
let mid = steps(&s, g)[1].geometric_bounds().unwrap();
assert!((mid.width() - 40.0).abs() < 1e-6 && (mid.height() - 40.0).abs() < 1e-6, "no twist: {mid:?}");
}
#[test]
fn dashes_caps_and_width_profiles_interpolate() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
s.execute("stroke.set", &json!({"ids": [a.0], "weight": 2, "dash": [10, 10], "cap": "butt"})).unwrap();
let b = rect(&mut s, 200.0, 0.0, 40.0, 40.0);
s.execute("stroke.set", &json!({"ids": [b.0], "weight": 6, "cap": "round", "profile": "lens"})).unwrap();
let g = make(&mut s, &[a, b], 3);
let st = steps(&s, g);
let stroke = |n: &Node| n.appearance.stroke().cloned().unwrap();
let (first, last) = (stroke(&st[1]), stroke(&st[3]));
assert_eq!(first.dash.as_ref().unwrap().pattern, vec![12.5, 7.5], "the gaps close towards the solid key");
assert!(last.dash.as_ref().unwrap().pattern[1] < first.dash.as_ref().unwrap().pattern[1]);
assert_eq!((first.cap, last.cap), (vectorcraft_doc::LineCap::Butt, vectorcraft_doc::LineCap::Round), "caps switch halfway");
assert_eq!(stroke(&st[2]).profile.unwrap().at(0.0), (0.5, 0.5), "the lens profile comes in gradually");
}
#[test]
fn groups_with_different_member_counts_interpolate_member_by_member() {
let mut s = session();
let a1 = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let a2 = rect(&mut s, 0.0, 40.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [a1.0, a2.0]})).unwrap();
let ga = id_of(&s.execute("object.group", &json!({})).unwrap());
let b1 = rect(&mut s, 200.0, 0.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [b1.0]})).unwrap();
let gb = id_of(&s.execute("object.group", &json!({})).unwrap());
let g = make(&mut s, &[ga, gb], 1);
let mid = &steps(&s, g)[1];
let NodeKind::Group { children, .. } = &mid.kind else { panic!("a group step") };
assert_eq!(children.len(), 2, "the member only one group has grows out of the other's centre");
let c = children[0].geometric_bounds().unwrap();
assert!((c.x0 - 100.0).abs() < 1e-6 && (c.width() - 20.0).abs() < 1e-6, "{c:?}");
assert!(children[1].geometric_bounds().unwrap().width() < 15.0);
}
#[test]
fn compound_steps_stay_compound_paths() {
let mut s = session();
let mk = |s: &mut Session, x: f64| {
let o = rect(s, x, 0.0, 40.0, 40.0);
let i = rect(s, x + 10.0, 10.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [o.0, i.0]})).unwrap();
id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap())
};
let (a, b) = (mk(&mut s, 0.0), mk(&mut s, 200.0));
let g = make(&mut s, &[a, b], 1);
let mid = &steps(&s, g)[1];
assert!(matches!(&mid.kind, NodeKind::Compound { children, .. } if children.len() == 2), "{}", mid.kind_label());
}
#[test]
fn text_moves_and_recolours_while_its_words_switch_halfway() {
let mut s = session();
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 100, "text": "Hi", "size": 20, "color": "#000000"})).unwrap());
let b = id_of(&s.execute("text.create", &json!({"x": 200, "y": 100, "text": "Yo", "size": 40, "color": "#ffffff"})).unwrap());
let g = make(&mut s, &[a, b], 3);
let st = steps(&s, g);
let NodeKind::Text(t) = &st[2].kind else { panic!("a text step") };
assert!((t.xf.as_coeffs()[4] - 100.0).abs() < 1e-6, "halfway along");
let run = &t.runs[0];
assert!((run.style.size - 30.0).abs() < 1e-6);
assert!((run.style.fill.color().unwrap().to_rgb()[0] - 0.5).abs() < 1e-3, "colour halfway");
let NodeKind::Text(t1) = &st[1].kind else { panic!() };
assert_eq!(t1.plain_text(), "Hi", "the words switch only halfway");
}
#[test]
fn symbol_instances_interpolate_their_transforms() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let a = id_of(&s.execute("symbol.new", &json!({"name": "Dot"})).unwrap());
let b = id_of(&s.execute("symbol.place", &json!({"name": "Dot", "x": 210, "y": 10})).unwrap());
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
let g = make(&mut s, &[a, b], 1);
let mid = &steps(&s, g)[1];
let NodeKind::SymbolInstance { xf, .. } = &mid.kind else { panic!("{}", mid.kind_label()) };
let c = xf.as_coeffs();
assert!((c[1].atan2(c[0]).to_degrees().abs() - 45.0).abs() < 1e-6, "turned halfway: {c:?}");
assert!((mid.geometric_bounds().unwrap().center().x - 110.0).abs() < 1e-6);
}
#[test]
fn knockout_blends_draw_as_they_export() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
let b = rect(&mut s, 20.0, 0.0, 40.0, 40.0);
for id in [a, b] {
s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [id.0], "color": null})).ok();
s.execute("object.setProps", &json!({"ids": [id.0], "opacity": 50})).unwrap();
}
let g = make(&mut s, &[a, b], 3);
assert_eq!(node(&s, g).knockout, Knockout::On, "blends knock out by default");
let px = |s: &Session| {
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 60.0, 40.0), 1.0, true).pixel(30, 20)
};
let on = px(&s);
assert!((i32::from(on[1]) - 128).abs() <= 3, "one layer of 50 % red over white: {on:?}");
s.execute("object.setProps", &json!({"ids": [g.0], "knockout": "off"})).unwrap();
let off = px(&s);
assert!(off[1] < 100, "the steps build up without knockout: {off:?}");
// Opaque steps don't need a knockout group in the outputs.
let mut t = session();
let (c, d) = (rect(&mut t, 0.0, 0.0, 10.0, 10.0), rect(&mut t, 100.0, 0.0, 10.0, 10.0));
let h = make(&mut t, &[c, d], 3);
assert_eq!(vectorcraft_doc::live::expanded_group(&node(&t, h), None).knockout, Knockout::Off);
}
#[test]
fn expand_and_release_keep_what_the_blend_carries() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
let g = make(&mut s, &[a, b], 2);
s.execute("object.setProps", &json!({"ids": [g.0], "name": "Fade", "opacity": 40, "isolate": true})).unwrap();
s.edit("mask", |d, _| {
let art = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 10.0)), Default::default());
let mask = vectorcraft_doc::OpacityMask::new(art, false);
d.node_mut(g).unwrap().mask = Some(Box::new(mask));
Ok(())
})
.unwrap();
let before = node(&s, g);
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
s.execute("object.blend.expand", &json!({})).unwrap();
let e = node(&s, g);
assert!(matches!(e.kind, NodeKind::Group { .. }));
assert_eq!((e.name.as_deref(), e.opacity, e.isolate, e.knockout), (Some("Fade"), before.opacity, true, Knockout::On));
assert_eq!(e.mask, before.mask);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
let r = s.execute("object.blend.release", &json!({})).unwrap();
let grp = NodeId(r["groups"][0].as_u64().unwrap());
let n = node(&s, grp);
assert_eq!((n.name.as_deref(), n.opacity, n.isolate), (Some("Fade"), before.opacity, true));
assert_eq!(n.mask, before.mask);
assert_eq!(n.children().unwrap().len(), 3, "spine and keys inside");
}
#[test]
fn object_expand_expands_blends_inside_the_selection() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
let g = make(&mut s, &[a, b], 2);
let c = rect(&mut s, 0.0, 100.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap();
let outer = id_of(&s.execute("object.group", &json!({})).unwrap());
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true));
s.execute("object.expand", &json!({"object": true, "fill": false, "stroke": false})).unwrap();
let inner = node(&s, g);
assert!(matches!(inner.kind, NodeKind::Group { .. }), "the blend inside the group expanded");
assert_eq!(inner.children().unwrap().len(), 4);
assert!(s.doc().unwrap().doc.node(outer).is_some());
let _ = Paint::None;
let _ = Point::ZERO;
}
+155
View File
@@ -0,0 +1,155 @@
//! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool:
//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18).
use serde_json::{Value, json};
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn spec(s: &Session, id: NodeId) -> (Vec<NodeId>, BlendSpec) {
match node(s, id).kind {
NodeKind::Blend { children, spec } => (children.iter().map(|c| c.id).collect(), spec),
_ => panic!("not a blend"),
}
}
fn sel(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
#[test]
fn options_with_nothing_selected_set_what_new_blends_start_with() {
let mut s = session();
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!((info["target"].as_str(), info["spacing"].as_str()), (Some("defaults"), Some("smooth")));
let depth = s.doc().unwrap().history.undo.len();
let r = s.execute("object.blend.options", &json!({"spacing": "distance", "value": 20, "orientation": "path"})).unwrap();
assert_eq!(r["defaults"], json!(true));
assert_eq!(s.doc().unwrap().history.undo.len(), depth, "a tool setting, not an undo step");
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!((info["spacing"].as_str(), info["distance"].as_f64(), info["orientation"].as_str()), (Some("distance"), Some(20.0), Some("path")));
// New blends start with them; explicit params still win.
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
let (_, sp) = spec(&s, g);
assert_eq!((sp.spacing, sp.orientation), (BlendSpacing::Distance(20.0), BlendOrientation::AlignToPath));
s.execute("edit.undo", &json!({})).unwrap();
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap());
assert_eq!(spec(&s, g).1.spacing, BlendSpacing::Steps(2));
// The preference survives a round trip through the preferences file.
let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap();
assert_eq!(back.blend_options, s.prefs.blend_options);
assert!(Prefs::default().to_json().get("blendOptions").is_none());
}
#[test]
fn info_reads_the_selected_blend() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 4})).unwrap());
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!(info["target"], "blend");
assert_eq!(info["id"], json!(g.0));
assert_eq!((info["spacing"].as_str(), info["steps"].as_u64()), (Some("steps"), Some(4)));
assert_eq!(info["keys"], json!([a.0, b.0]));
}
#[test]
fn start_points_pair_the_clicked_anchors() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
// a starts at its top left, b at its bottom right: corners meet their opposites.
let g = id_of(&s.execute("object.blend.make", &json!({"ids": [b.0, a.0], "starts": [2, 0], "steps": 1})).unwrap());
let (keys, sp) = spec(&s, g);
assert_eq!(keys, vec![a, b], "keys in paint order");
assert_eq!(sp.starts, vec![Some(0), Some(2)], "each start stays with its object");
let steps = vectorcraft_doc::live::expand_live(&node(&s, g));
let mid = steps[1].geometric_bounds().unwrap();
assert!(mid.height() < 1e-6, "the middle step collapses where opposite corners meet: {mid:?}");
assert!(s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": "x"})).is_err());
assert!(s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": [-1, 0]})).is_err());
// Reverse Front to Back keeps each key's start.
sel(&mut s, &[g]);
s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap();
assert_eq!(spec(&s, g).1.starts, vec![Some(2), Some(0)]);
}
#[test]
fn a_blend_and_an_object_make_one_blend_with_more_keys() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0), rect(&mut s, 200.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap());
s.edit("name", |d, _| {
d.node_mut(g).unwrap().name = Some("Ribbon".into());
Ok(())
})
.unwrap();
let g2 = id_of(&s.execute("object.blend.make", &json!({"ids": [g.0, c.0]})).unwrap());
let n = node(&s, g2);
let (keys, sp) = spec(&s, g2);
assert_eq!(keys, vec![a, b, c], "no blend inside the blend");
assert_eq!(sp.spacing, BlendSpacing::Steps(3), "the blend's options are kept");
assert_eq!(n.name.as_deref(), Some("Ribbon"));
assert!(s.doc().unwrap().doc.node(g).is_none());
assert_eq!(vectorcraft_doc::live::expand_live(&n).len(), 3 + 2 * 3);
}
#[test]
fn the_blend_tool_blends_from_clicked_anchors_and_keeps_adding() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
s.select_tool("blend", ViewInfo::default()).unwrap();
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
// a's top right anchor, then b's interior.
click(&mut s, 40.0, 0.0);
click(&mut s, 220.0, 20.0);
let g = s.doc().unwrap().selection.objects[0];
let (keys, sp) = spec(&s, g);
assert_eq!(keys, vec![a, b]);
assert_eq!(sp.starts, vec![Some(1), None]);
// The next object joins the same blend.
click(&mut s, 420.0, 20.0);
let g2 = s.doc().unwrap().selection.objects[0];
assert_eq!(spec(&s, g2).0, vec![a, b, c]);
// Alt-click asks for Blend Options.
let r = s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 500.0).with_mods(Mods { alt: true, ..Default::default() }), ViewInfo::default());
assert!(matches!(r.unwrap().as_slice(), [UiRequest::Dialog(k, _)] if k == "blendOptions"));
}
#[test]
fn start_points_round_trip_and_old_blends_load() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": [null, 3]})).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(&back.layers, &d.layers);
// A blend saved before start points has none.
let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}, "orientation": "alignToPage"})).unwrap();
assert!(old.starts.is_empty() && old.start(0).is_none());
assert!(serde_json::to_value(&old).unwrap().get("starts").is_none());
}
+189
View File
@@ -0,0 +1,189 @@
//! A blend's spine and keys on the canvas (M8.19): spine points moved, added and deleted (the
//! straight spine becomes a path on the first edit), keys picked and edited live, Release keeping
//! the spine as a path.
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::live::BlendSpec;
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Point, Rect};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn spec(s: &Session, id: NodeId) -> BlendSpec {
match node(s, id).kind {
NodeKind::Blend { spec, .. } => spec,
_ => panic!("not a blend"),
}
}
fn center(n: &Node) -> Point {
n.geometric_bounds().unwrap().center()
}
/// Two 10 pt squares centred on (5, 5) and (105, 5), blended with one step.
fn blend(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
let b = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 10, "height": 10})).unwrap());
let g = id_of(&s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap());
(a, b, g)
}
fn steps(s: &Session, g: NodeId) -> Vec<Node> {
vectorcraft_doc::live::expand_live(&node(s, g))
}
#[test]
fn a_spine_point_added_and_moved_bends_the_blend() {
let mut s = session();
let (_, _, g) = blend(&mut s);
assert!(spec(&s, g).spine.is_none(), "straight until edited");
let r = s.execute("object.blend.spine.addAnchor", &json!({"x": 55, "y": 8})).unwrap();
assert_eq!(r["anchor"], json!(1));
let sp = spec(&s, g);
assert_eq!(sp.key_anchors, vec![0, 2], "the keys keep their points");
assert!(sp.spine.unwrap().subpaths[0].anchors[1].p.distance(Point::new(55.0, 5.0)) < 1e-9, "on the spine, nearest the click");
s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 105})).unwrap();
let st = steps(&s, g);
assert!(center(&st[1]).distance(Point::new(55.0, 105.0)) < 1e-6, "the step follows the spine: {:?}", center(&st[1]));
assert_eq!(center(&st[0]), Point::new(5.0, 5.0), "the keys stay put");
// One undo step each.
s.execute("edit.undo", &json!({})).unwrap();
assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 5.0)) < 1e-6);
}
#[test]
fn moving_a_key_point_moves_the_key_and_the_other_way_round() {
let mut s = session();
let (a, b, g) = blend(&mut s);
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 0, "x": 5, "y": 105})).unwrap();
assert_eq!(center(&node(&s, a)), Point::new(5.0, 105.0));
// A handle drag curves the spine without moving the key.
s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 0, "handle": "out", "x": 5, "y": 300})).unwrap();
assert_eq!(center(&node(&s, a)), Point::new(5.0, 105.0));
assert!(center(&steps(&s, g)[1]).y > 100.0, "the step bows out");
// Editing a key on the canvas (it is selectable inside the blend) drags its point along.
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
s.execute("object.move", &json!({"dx": 0, "dy": 50})).unwrap();
let n = node(&s, g);
let NodeKind::Blend { children, spec } = &n.kind else { panic!() };
let (path, _) = vectorcraft_doc::live::blend_spine(children, spec).unwrap();
assert_eq!(path.subpaths[0].anchors[1].p, Point::new(105.0, 55.0));
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 9, "x": 0, "y": 0})).is_err());
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 0, "x": f64::MAX, "y": 0})).is_err());
}
#[test]
fn only_points_without_a_key_can_be_deleted() {
let mut s = session();
let (_, _, g) = blend(&mut s);
assert!(s.execute("object.blend.spine.removeAnchor", &json!({"id": g.0, "anchor": 0})).is_err());
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 30, "y": 5})).unwrap();
s.execute("object.blend.spine.removeAnchor", &json!({"id": g.0, "anchor": 1})).unwrap();
let sp = spec(&s, g);
assert_eq!((sp.spine.unwrap().subpaths[0].anchors.len(), sp.key_anchors), (2, vec![0, 1]));
}
#[test]
fn info_lists_the_spine() {
let mut s = session();
let (_, _, g) = blend(&mut s);
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!(info["spine"]["explicit"], json!(false));
assert_eq!(info["spine"]["keyAnchors"], json!([0, 1]));
assert_eq!(info["spine"]["anchors"][1]["x"], json!(105.0));
let _ = g;
}
#[test]
fn release_keeps_the_spine_as_a_path() {
let mut s = session();
let (a, b, g) = blend(&mut s);
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap();
let r = s.execute("object.blend.release", &json!({})).unwrap();
assert_eq!(r["ids"], json!([a.0, b.0]));
let spine = NodeId(r["spines"][0].as_u64().unwrap());
let n = node(&s, spine);
assert!(n.appearance.fill_paint().is_none() && n.appearance.stroke_paint() == Paint::None, "paints nothing");
let pts: Vec<Point> = n.path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect();
assert_eq!(pts, vec![Point::new(5.0, 5.0), Point::new(55.0, 60.0), Point::new(105.0, 5.0)]);
let d = &s.doc().unwrap().doc;
let layer = d.layers[0].children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
assert_eq!(layer, vec![spine, a, b], "the spine below the keys");
}
#[test]
fn reverse_spine_and_front_to_back_keep_an_edited_spine() {
let mut s = session();
let (a, b, g) = blend(&mut s);
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap();
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
s.execute("object.blend.reverseSpine", &json!({})).unwrap();
assert_eq!((center(&node(&s, a)), center(&node(&s, b))), (Point::new(105.0, 5.0), Point::new(5.0, 5.0)), "the keys swap ends");
assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 60.0)) < 1e-6, "the spine keeps its shape");
s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap();
assert_eq!(center(&node(&s, a)), Point::new(105.0, 5.0), "stacking changes, places don't");
assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 60.0)) < 1e-6);
}
#[test]
fn a_blend_with_an_edited_spine_takes_more_keys_along_it() {
let mut s = session();
let (a, b, g) = blend(&mut s);
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap();
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap());
let g2 = id_of(&s.execute("object.blend.make", &json!({"ids": [g.0, c.0]})).unwrap());
let sp = spec(&s, g2);
assert_eq!(sp.key_anchors, vec![0, 2, 3]);
let pts: Vec<Point> = sp.spine.unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts[1], Point::new(55.0, 60.0), "the bend is kept");
assert_eq!(pts[3], Point::new(205.0, 5.0));
let _ = (a, b);
}
#[test]
fn direct_selection_picks_keys_and_edits_update_the_blend_live() {
let mut s = session();
let (a, _, g) = blend(&mut s);
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#000000"})).ok();
let opt = vectorcraft_doc::hit::HitOptions::default();
let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, Point::new(3.0, 3.0), opt).unwrap();
assert_eq!((h.leaf, h.top_object(None)), (a, g), "the key is hit; the Selection tool still takes the blend");
let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, Point::new(55.0, 5.0), opt).unwrap();
assert_eq!(h.leaf, g, "a step hits as the blend");
let mut r = vectorcraft_render::Renderer::new();
let before = r.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 120.0, 20.0), 1.0, true).pixel(55, 5);
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap();
let after = r.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 120.0, 20.0), 1.0, true).pixel(55, 5);
assert_ne!(before, after, "the step redraws with the key's new colour");
}
#[test]
fn key_anchors_round_trip() {
let mut s = session();
let (_, _, g) = blend(&mut s);
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(&back.layers, &d.layers);
let old: BlendSpec = serde_json::from_value(json!({"spine": {"subpaths": []}})).unwrap();
assert!(old.key_anchors.is_empty());
}
+16 -6
View File
@@ -207,13 +207,19 @@ fn puppet_tool_drag_warps_selection() {
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("puppetWarp", v).unwrap();
// First click seeds pins and adds one at the right end, then drag it.
s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 150.0), v).unwrap();
// The automatic pins show at once, one in the middle of each end: drag the right end's up.
let pins = s.execute("object.puppetWarp.pins", &json!({})).unwrap()["moved"].clone();
let right = pins
.as_array()
.unwrap()
.iter()
.map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap()))
.fold((0.0, 0.0), |a, q| if q.0 > a.0 { q } else { a });
assert!(right.0 > 350.0, "{pins}");
let n = undo_len(&s);
s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 385.0, 120.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 120.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, right.0, right.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, right.0, right.1 - 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, right.0, right.1 - 30.0), v).unwrap();
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.journal.last().unwrap().0, "object.puppetWarp");
assert!(path(&s, a).bounds().unwrap().y0 < 95.0);
@@ -290,6 +296,8 @@ fn rectangle_tool_draws_in_perspective_on_the_active_plane() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
// Snap to Grid (on by default) would move the dragged corners onto gridlines.
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
let n = undo_len(&s);
gesture(&mut s, "rectangle", &[(300.0, 320.0), (330.0, 360.0), (350.0, 400.0)], Mods::default());
assert_eq!(undo_len(&s), n + 1);
@@ -313,6 +321,8 @@ fn perspective_selection_moves_within_the_plane() {
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
// Snap to Grid (on by default) would land the moved edges on gridlines.
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
let g = grid(&s);
let before: Vec<Point> = path(&s, a).anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect();
let centre = before.iter().fold(vectorcraft_geom::Vec2::ZERO, |a, p| a + p.to_vec2()) / before.len() as f64;
+276
View File
@@ -0,0 +1,276 @@
//! Envelope Distort: Reset with Warp / Mesh, Envelope Options, Release, Expand and Object → Expand,
//! driven through `Session::execute`.
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::live::{self, EnvelopeKind, EnvelopeOptions, PreserveShape};
use vectorcraft_doc::{Knockout, Node, NodeKind, OpacityMask};
use vectorcraft_geom::Rect;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn sel(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
/// A 200×100 rectangle at (100, 100) enveloped by `cmd` with `params` → (rectangle, envelope).
fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) {
let a = rect(s, 100.0, 100.0, 200.0, 100.0);
sel(s, &[a]);
(a, id_of(&s.execute(cmd, &params).unwrap()))
}
fn kind(s: &Session, id: NodeId) -> EnvelopeKind {
match node(s, id).kind {
NodeKind::Envelope { kind, .. } => kind,
k => panic!("{k:?}"),
}
}
fn options(s: &Session, id: NodeId) -> (EnvelopeOptions, f64) {
match node(s, id).kind {
NodeKind::Envelope { options, fidelity, .. } => (options, fidelity),
k => panic!("{k:?}"),
}
}
#[test]
fn new_envelopes_take_the_reference_defaults_and_options_change_them() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({}));
assert_eq!(options(&s, e), (EnvelopeOptions::NEW, 50.0));
s.execute(
"object.envelope.options",
&json!({"antiAlias": false, "preserveShape": "transparency", "distortAppearance": true, "distortLinearGradients": true, "distortPatternFills": true, "fidelity": 75}),
)
.unwrap();
let (o, f) = options(&s, e);
assert_eq!((o.anti_alias, o.preserve_shape, o.gradients(), o.patterns(), f), (false, PreserveShape::Transparency, true, true, 75.0));
let info = s.execute("object.envelope.info", &json!({})).unwrap();
assert_eq!(info["id"], json!(e.0));
assert_eq!((info["type"].clone(), info["style"].clone(), info["preserveShape"].clone()), (json!("warp"), json!("arc"), json!("transparency")));
assert!(s.execute("object.envelope.options", &json!({"preserveShape": "alpha"})).is_err());
assert!(s.execute("object.envelope.options", &json!({"fidelity": -1})).is_err());
}
#[test]
fn options_with_nothing_selected_are_the_defaults_for_new_envelopes() {
let mut s = session();
s.execute("object.envelope.options", &json!({"distortAppearance": false, "fidelity": 20})).unwrap();
let info = s.execute("object.envelope.info", &json!({})).unwrap();
assert_eq!((info["id"].clone(), info["distortAppearance"].clone(), info["fidelity"].clone()), (Value::Null, json!(false), json!(20.0)));
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 3}));
let (o, f) = options(&s, e);
assert_eq!((o.distort_appearance, f), (false, 20.0));
assert!(matches!(kind(&s, e), EnvelopeKind::Mesh { rows: 2, cols: 3, .. }));
}
#[test]
fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() {
let mut s = session();
let (a, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 2}));
s.execute("object.envelope.resetWithWarp", &json!({"style": "arch", "bend": 60, "orientation": "vertical"})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { ref style, bend, horizontal: false, .. } if style == "arch" && bend == 60.0));
assert_eq!(node(&s, e).children().unwrap()[0].id, a);
// Unset values keep the current warp's.
s.execute("object.envelope.resetWithWarp", &json!({"bend": -20})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { ref style, bend, horizontal: false, .. } if style == "arch" && bend == -20.0));
assert!(s.execute("object.envelope.resetWithWarp", &json!({"style": "nope"})).is_err());
// Back to a mesh that keeps the arch's shape: the top middle point is where the warp put it.
s.execute("object.envelope.resetWithWarp", &json!({"style": "arch", "bend": 50, "horizontal": true})).unwrap();
let top = live::EnvelopeMap::of(&node(&s, e)).unwrap().surface_mesh(2, 2, vectorcraft_color::Color::BLACK);
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2})).unwrap();
let EnvelopeKind::Mesh { rows: 2, cols: 2, points, .. } = kind(&s, e) else { panic!("{:?}", kind(&s, e)) };
assert!(points[1].distance(top.points[1].p) < 1e-9 && points[1].y < 99.0, "{:?}", points[1]);
// Without Maintain Envelope Shape: a flat grid over the content.
s.execute("object.envelope.resetWithMesh", &json!({"rows": 1, "cols": 1, "maintainShape": false})).unwrap();
assert_eq!(
kind(&s, e),
EnvelopeKind::Mesh { rows: 1, cols: 1, points: live::grid_points(Rect::new(100.0, 100.0, 300.0, 200.0), 1, 1), handles: vec![] }
);
assert!(s.execute("object.envelope.resetWithMesh", &json!({"rows": 0})).is_err());
// One undo step per reset.
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Mesh { rows: 2, cols: 2, .. }));
// Nothing to reset without an envelope.
sel(&mut s, &[]);
assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err());
}
#[test]
fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() {
let mut s = session();
let (a, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
s.execute("transparency.set", &json!({"opacity": 40, "blend": "multiply"})).unwrap();
let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default());
s.edit("mask", |d, _| {
d.node_mut(e).unwrap().mask = Some(Box::new(OpacityMask::new(mask, true)));
Ok(())
})
.unwrap();
let r = s.execute("object.envelope.release", &json!({})).unwrap();
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids.len(), 2);
assert_eq!(ids[0], a);
let content = node(&s, a);
assert_eq!((content.opacity, content.blend, content.mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look");
let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") };
// The mesh follows the arc: its outline reaches below the content's bottom edge at the sides.
let b = m.outline().bounds().unwrap();
assert!(b.y1 > 201.0, "{b:?}");
assert_eq!(s.doc().unwrap().selection.objects, ids);
}
#[test]
fn release_groups_several_objects_under_the_transparency() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
let b = rect(&mut s, 200.0, 100.0, 50.0, 50.0);
sel(&mut s, &[a, b]);
let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 2})).unwrap());
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
let r = s.execute("object.envelope.release", &json!({})).unwrap();
let g = node(&s, NodeId(r["ids"][0].as_u64().unwrap()));
assert_eq!(g.opacity, 0.5);
assert_eq!(g.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), vec![a, b]);
assert!(matches!(node(&s, NodeId(r["ids"][1].as_u64().unwrap())).kind, NodeKind::Mesh(ref m) if (m.rows, m.cols) == (1, 2)));
assert!(s.doc().unwrap().doc.node(e).is_none());
}
#[test]
fn expand_keeps_name_transparency_knockout_and_mask() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "bulge", "bend": 40}));
s.execute("transparency.set", &json!({"opacity": 70, "isolate": true, "knockout": "on"})).unwrap();
let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default());
s.edit("name and mask", |d, _| {
let n = d.node_mut(e).unwrap();
n.name = Some("Banner".into());
n.mask = Some(Box::new(OpacityMask::new(mask, true)));
Ok(())
})
.unwrap();
s.execute("object.envelope.expand", &json!({})).unwrap();
let g = node(&s, e);
assert!(matches!(g.kind, NodeKind::Group { .. }));
assert_eq!((g.name.as_deref(), g.opacity, g.isolate, g.knockout, g.mask.is_some()), (Some("Banner"), 0.7, true, Knockout::On, true));
}
#[test]
fn object_expand_expands_envelopes_inside_the_selection() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arch", "bend": 50}));
let other = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
sel(&mut s, &[e, other]);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
let info = s.execute("object.expand.info", &json!({})).unwrap();
assert_eq!(info["object"], json!(true));
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let mut envelopes = 0;
node(&s, g).walk(&mut |n| envelopes += matches!(n.kind, NodeKind::Envelope { .. }) as usize);
assert_eq!(envelopes, 0);
// The arch lifted the expanded art's middle above the rectangle's top edge.
let b = node(&s, e).geometric_bounds().unwrap();
assert!(b.y0 < 99.0, "{b:?}");
}
#[test]
fn envelope_options_round_trip_natively_and_old_files_load() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({}));
s.execute("object.envelope.options", &json!({"preserveShape": "transparency", "distortPatternFills": true})).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(back.node(e), d.node(e));
// An envelope saved before the options existed loads with its old behaviour.
let mut old = serde_json::to_value(node(&s, e)).unwrap();
old["kind"].as_object_mut().unwrap().remove("options");
let n: Node = serde_json::from_value(old).unwrap();
assert!(matches!(n.kind, NodeKind::Envelope { options, .. } if options == EnvelopeOptions::default() && !options.distort_appearance));
// The old behaviour's options aren't written.
let mut legacy = node(&s, e);
if let NodeKind::Envelope { options, .. } = &mut legacy.kind {
*options = EnvelopeOptions::default();
}
assert!(serde_json::to_value(&legacy).unwrap()["kind"].get("options").is_none());
}
/// Regression: every exporter wrote type inside an envelope undistorted (no text outliner), so a
/// warped line of type exported the same whatever its bend.
#[test]
fn enveloped_type_exports_distorted_in_every_format() {
let mut s = session();
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Envelope", "size": 48})).unwrap());
sel(&mut s, &[t]);
s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 0})).unwrap();
// The export's bytes without its time stamps (EPS and PDF date their files to the second).
let export = |s: &mut Session, format: &str| -> Vec<u8> {
let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].as_str().unwrap().to_string();
let bytes = vectorcraft_format::base64_decode(&data).unwrap();
let dated = |line: &&[u8]| line.windows(12).any(|w| w == b"CreationDate") || line.windows(7).any(|w| w == b"ModDate");
bytes.split(|b| *b == b'\n').filter(|l| !dated(l)).flat_map(|l| l.iter().copied().chain(*b"\n")).collect()
};
for format in ["svg", "pdf", "eps", "emf", "dxf"] {
s.execute("object.envelope.options", &json!({"bend": 0})).unwrap();
let flat = export(&mut s, format);
assert_eq!(export(&mut s, format), flat, "{format} exports are repeatable");
s.execute("object.envelope.options", &json!({"bend": 80})).unwrap();
assert_ne!(export(&mut s, format), flat, "{format}: the bend shows in the export");
}
let svg = String::from_utf8(export(&mut s, "svg")).unwrap();
assert!(!svg.contains("<text"), "type in an envelope exports as its distorted outlines");
}
/// Regression: on the canvas, type in an envelope was outlined in its first run's paint only.
#[test]
fn enveloped_type_keeps_each_runs_colour_on_the_canvas() {
let mut s = session();
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "MMMMMMMM", "size": 60, "fill": "#000000"})).unwrap());
s.edit("red half", |d, _| {
if let NodeKind::Text(tx) = &mut d.node_mut(t).unwrap().kind {
vectorcraft_text::edit::style_range(&mut tx.runs, 0, 4, |st| {
st.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))
});
}
Ok(())
})
.unwrap();
sel(&mut s, &[t]);
s.execute("object.envelope.makeWithWarp", &json!({"style": "flag", "bend": 0})).unwrap();
let b = node(&s, t).geometric_bounds().unwrap();
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 800.0, 400.0), 1.0, true);
// Inked pixels of each half of the line: red at the start, black at the end.
let (mut red, mut black) = (0, 0);
for y in (b.y0 as u32)..(b.y1 as u32) {
for x in (b.x0 as u32)..(b.x1 as u32) {
let p = img.pixel(x, y);
let left = (x as f64) < b.center().x;
if p[0] > 200 && p[1] < 60 {
assert!(left, "red at ({x}, {y})");
red += 1;
} else if p[0] < 60 && p[1] < 60 && p[3] > 200 {
assert!(!left, "black at ({x}, {y})");
black += 1;
}
}
}
assert!(red > 100 && black > 100, "{red} red, {black} black");
}
+298
View File
@@ -0,0 +1,298 @@
//! What envelopes distort: the envelope's own frame under transforms, images (a raster mesh warp),
//! symbols, linear gradients, pattern fills, strokes and effects (Distort Appearance).
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
use vectorcraft_doc::live::EnvelopeOptions;
use vectorcraft_doc::{AppearanceItem, Node, NodeKind, PatternDef, Symbol};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_render::effects;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn sel(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
/// A 200×100 rectangle at (100, 100), filled red, no stroke.
fn rect(s: &mut Session) -> NodeId {
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap());
s.edit("red", |d, _| {
d.node_mut(a).unwrap().appearance = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
Ok(())
})
.unwrap();
a
}
/// Envelope the selection with an arch warp (`bend` %) and `options` → the envelope.
fn arch(s: &mut Session, bend: f64, options: Value) -> NodeId {
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": bend})).unwrap());
s.execute("object.envelope.options", &options).unwrap();
e
}
/// The envelope evaluated as the canvas and the exporters do.
fn evaluated(s: &Session, e: NodeId) -> Vec<Node> {
effects::expand_live(Some(&s.doc().unwrap().doc), &node(s, e))
}
fn anchors(nodes: &[Node]) -> Vec<Point> {
let mut v = vec![];
for n in nodes {
n.walk(&mut |c| v.extend(c.path_data().into_iter().flat_map(|p| p.anchors().map(|(_, _, a)| a.p))));
}
v
}
fn frame(s: &Session, e: NodeId) -> Affine {
match node(s, e).kind {
NodeKind::Envelope { frame, .. } => frame,
k => panic!("{k:?}"),
}
}
/// Regression: rotating a warp envelope rotated its content and re-warped it along the page axes.
#[test]
fn a_rotated_warp_envelope_turns_with_its_warp() {
let mut s = session();
let a = rect(&mut s);
sel(&mut s, &[a]);
let e = arch(&mut s, 50.0, json!({}));
let before = anchors(&evaluated(&s, e));
let c = Point::new(200.0, 150.0);
s.execute("object.rotate", &json!({"angle": 90, "origin": [c.x, c.y]})).unwrap();
let after = anchors(&evaluated(&s, e));
let turn = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, c);
assert_eq!(before.len(), after.len());
for (p, q) in before.iter().zip(&after) {
assert!((turn * *p).distance(*q) < 1e-6, "{:?} vs {q:?}", turn * *p);
}
assert_ne!(frame(&s, e), Affine::IDENTITY);
// Turning back squares the frame with the page again; a plain move or scale never tilts it.
s.execute("object.rotate", &json!({"angle": -90, "origin": [c.x, c.y]})).unwrap();
assert_eq!(frame(&s, e), Affine::IDENTITY);
s.execute("object.scale", &json!({"sx": 150, "sy": 50})).unwrap();
assert_eq!(frame(&s, e), Affine::IDENTITY);
// A reflection keeps a frame: the arch flips with the art.
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
let flipped = anchors(&evaluated(&s, e));
let b = node(&s, e).geometric_bounds().unwrap();
assert!(flipped.iter().any(|p| p.y > b.y1 - 1.0) && frame(&s, e) != Affine::IDENTITY);
}
#[test]
fn the_frame_round_trips_natively() {
let mut s = session();
let a = rect(&mut s);
sel(&mut s, &[a]);
let e = arch(&mut s, 50.0, json!({}));
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(back.node(e), d.node(e));
let mut old = serde_json::to_value(node(&s, e)).unwrap();
old["kind"].as_object_mut().unwrap().remove("frame");
let n: Node = serde_json::from_value(old).unwrap();
assert!(matches!(n.kind, NodeKind::Envelope { frame, .. } if frame == Affine::IDENTITY), "older envelopes stand square to the page");
}
/// A selected embedded 40×40 pt red image at (100, 100).
fn red_image(s: &mut Session) -> NodeId {
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.edit("Place", |d, sel| {
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png));
let id = d.alloc_id();
let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0);
let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default() };
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?;
sel.set([id]);
Ok(id)
})
.unwrap()
}
#[test]
fn images_bend_as_a_raster_mesh_warp() {
let mut s = session();
let im = red_image(&mut s);
sel(&mut s, &[im]);
let e = arch(&mut s, 80.0, json!({}));
let out = evaluated(&s, e);
let mut pieces = 0;
out[0].walk(&mut |c| pieces += matches!(c.kind, NodeKind::Image(_)) as usize);
assert!(pieces >= 32, "{pieces} pieces");
// The arch lifts the image's middle above its top edge, not its corners.
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
let b = node(&s, e).geometric_bounds().unwrap();
assert!(b.y0 < 95.0, "{b:?}");
let mid = img.pixel(120, 97);
assert!(mid[0] > 200 && mid[1] < 60, "the middle rose: {mid:?}");
assert!(img.pixel(101, 95)[1] > 200, "the corner stayed: {:?}", img.pixel(101, 95));
// The pieces export as clipped images, bent in the SVG too.
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().to_string();
let svg = String::from_utf8(vectorcraft_format::base64_decode(&svg).unwrap()).unwrap();
assert!(svg.matches("<use").count() >= 32 && svg.contains("clip"), "{}", &svg[..svg.len().min(400)]);
assert_eq!(svg.matches("<image").count(), 1, "the pieces share the image's pixels");
}
#[test]
fn symbols_bend_with_the_envelope() {
let mut s = session();
let inst = s
.edit("symbol", |d, sel| {
// Symbol art is normalised to ±10 (its instance's transform sizes it).
let art = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
let id = d.alloc_id();
let xf = Affine::translate((200.0, 150.0)) * Affine::scale_non_uniform(10.0, 5.0);
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf }))?;
sel.set([id]);
Ok(id)
})
.unwrap();
sel(&mut s, &[inst]);
let e = arch(&mut s, 60.0, json!({}));
let pts = anchors(&evaluated(&s, e));
assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle");
// Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art).
let mut r = vectorcraft_render::Renderer::new();
let region = Rect::new(0.0, 0.0, 400.0, 300.0);
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60);
s.edit("edit symbol", |d, _| {
let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default());
d.symbols[0].art = Arc::new(art);
Ok(())
})
.unwrap();
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 200);
}
fn strokes(nodes: &[Node]) -> usize {
let mut n = 0;
for c in nodes {
c.walk(&mut |c| n += c.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count());
}
n
}
#[test]
fn distort_appearance_bends_strokes_and_effects_with_the_art() {
let mut s = session();
let a = rect(&mut s);
s.execute("paint.setStroke", &json!({"ids": [a.0], "color": "#000000"})).unwrap();
s.execute("stroke.set", &json!({"ids": [a.0], "weight": 8})).unwrap();
sel(&mut s, &[a]);
let e = arch(&mut s, 60.0, json!({"distortAppearance": true}));
let out = evaluated(&s, e);
assert_eq!(strokes(&out), 0, "the stroke became its bent outline");
// Off: the stroke paints along the bent path at its own weight.
s.execute("object.envelope.options", &json!({"distortAppearance": false})).unwrap();
assert_eq!(strokes(&evaluated(&s, e)), 1);
// Geometry effects bake into the art first too.
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
s.execute("effect.apply", &json!({"effect": "distort.zigZag", "params": {"size": 5, "ridges": 10}})).unwrap();
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
s.execute("object.envelope.options", &json!({"distortAppearance": true})).unwrap();
let out = evaluated(&s, e);
let mut effects_left = 0;
out[0].walk(&mut |c| effects_left += c.appearance.effects.len());
assert_eq!(effects_left, 0);
assert!(anchors(&out).len() > anchors(&[node(&s, a)]).len());
}
fn gradient_geom(nodes: &[Node]) -> Option<vectorcraft_color::GradientGeom> {
let mut g = None;
for n in nodes {
n.walk(&mut |c| {
if let Paint::Gradient(gp) = c.appearance.fill_paint() {
g = g.or(gp.geom);
}
});
}
g
}
#[test]
fn linear_gradients_bend_when_asked() {
let mut s = session();
let a = rect(&mut s);
s.edit("gradient", |d, _| {
d.node_mut(a).unwrap().appearance.set_fill(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))));
Ok(())
})
.unwrap();
sel(&mut s, &[a]);
let e = arch(&mut s, 60.0, json!({"distortAppearance": true}));
assert!(gradient_geom(&evaluated(&s, e)).is_none(), "off: the gradient fits the bent shape");
s.execute("object.envelope.options", &json!({"distortLinearGradients": true})).unwrap();
let g = gradient_geom(&evaluated(&s, e)).expect("on: the gradient is placed and follows the warp");
// Arch lifts the middle: the vector (left to right through the middle) moved up.
assert!(g.start.midpoint(g.end).y < 150.0 - 1.0, "{g:?}");
// It needs Distort Appearance.
s.execute("object.envelope.options", &json!({"distortAppearance": false})).unwrap();
assert!(gradient_geom(&evaluated(&s, e)).is_none());
}
#[test]
fn pattern_fills_bend_when_asked() {
let mut s = session();
let a = rect(&mut s);
s.edit("pattern", |d, _| {
let dot = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)),
vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
let mut def = PatternDef::new("Dots", vec![Arc::new(dot)]);
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
d.patterns.push(def);
d.node_mut(a).unwrap().appearance.set_fill(vectorcraft_doc::pattern::pattern_paint("Dots"));
Ok(())
})
.unwrap();
sel(&mut s, &[a]);
let e = arch(&mut s, 60.0, json!({"distortAppearance": true}));
let clip_groups = |out: &[Node]| {
let mut n = 0;
out[0].walk(&mut |c| n += matches!(c.kind, NodeKind::Group { clip: true, .. }) as usize);
n
};
assert_eq!(clip_groups(&evaluated(&s, e)), 0, "off: the pattern fills the bent shape unbent");
s.execute("object.envelope.options", &json!({"distortPatternFills": true})).unwrap();
let out = evaluated(&s, e);
assert_eq!(clip_groups(&out), 1);
assert!(anchors(&out).len() > 50 * 4, "the tiles are art");
assert!(!matches!(out[0].appearance.fill_paint(), Paint::Pattern { .. }));
}
#[test]
fn new_envelopes_distort_appearance_and_old_ones_keep_their_look() {
let mut s = session();
let a = rect(&mut s);
sel(&mut s, &[a]);
let e = arch(&mut s, 50.0, json!({}));
assert!(matches!(node(&s, e).kind, NodeKind::Envelope { options, .. } if options == EnvelopeOptions::NEW));
}
+136
View File
@@ -0,0 +1,136 @@
//! Editing envelopes on the canvas: Edit Contents hit-testing, mesh points and handles.
use serde_json::{Value, json};
use vectorcraft_doc::hit::{HitOptions, hit_test};
use vectorcraft_doc::live::{self, EnvelopeMap};
use vectorcraft_doc::{EnvelopeKind, Node, NodeKind};
use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_tools::{PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
/// A 200×100 rectangle at (100, 100) enveloped by `cmd` → (rectangle, envelope).
fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) {
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap());
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
(a, id_of(&s.execute(cmd, &params).unwrap()))
}
fn mesh(s: &Session, e: NodeId) -> (u32, u32, Vec<Point>, Vec<[Vec2; 4]>) {
match node(s, e).kind {
NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. } => (rows, cols, points, handles),
k => panic!("{k:?}"),
}
}
#[test]
fn edit_contents_clicks_reach_the_content_where_it_is() {
let mut s = session();
let (a, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arch", "bend": 50}));
let doc = |s: &Session| s.doc().unwrap().doc.clone();
// Inside the rectangle near its bottom middle, which the arch lifted away.
let below = Point::new(200.0, 195.0);
let h = hit_test(&doc(&s), below, HitOptions::default()).unwrap();
assert_eq!((h.leaf, h.top_object(None), h.contents_of), (e, e, None), "the envelope is one object");
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
let h = hit_test(&doc(&s), below, HitOptions::default()).unwrap();
assert_eq!((h.leaf, h.top_object(None), h.contents_of), (a, a, Some(e)), "Edit Contents: the content is the object clicked");
// The Selection tool picks it.
s.execute("select.none", &json!({})).unwrap();
s.select_tool("selection", ViewInfo::default()).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 200.0, 195.0), ViewInfo::default()).unwrap();
}
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
}
#[test]
fn mesh_handles_start_from_the_smooth_mesh() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 2}));
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": 210, "y": 130})).unwrap();
assert!(mesh(&s, e).3.is_empty(), "moving a point keeps the smooth mesh");
let smooth = |s: &Session, u: f64, v: f64| EnvelopeMap::of(&node(s, e)).unwrap().at(u, v);
let before: Vec<Point> = (0..=8).flat_map(|i| [(i as f64 / 8.0, 0.5), (0.5, i as f64 / 8.0)]).map(|(u, v)| smooth(&s, u, v)).collect();
// Placing a handle where it already is fills in the handles: the grid lines don't move.
let (rows, cols, points, _) = mesh(&s, e);
let h = live::smooth_handles(rows, cols, &points)[4][live::H_RIGHT];
let end = points[4] + h;
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": end.x, "y": end.y, "handle": 0})).unwrap();
assert_eq!(mesh(&s, e).3.len(), 9);
let after: Vec<Point> = (0..=8).flat_map(|i| [(i as f64 / 8.0, 0.5), (0.5, i as f64 / 8.0)]).map(|(u, v)| smooth(&s, u, v)).collect();
for (p, q) in before.iter().zip(&after) {
assert!(p.distance(*q) < 1e-6, "{p:?} vs {q:?}");
}
// Now the handle bends the row line up right of the point.
let y0 = smooth(&s, 0.62, 0.5).y;
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": end.x, "y": end.y - 40.0, "handle": 0})).unwrap();
assert!(smooth(&s, 0.62, 0.5).y < y0 - 1.0, "{} vs {y0}", smooth(&s, 0.62, 0.5).y);
assert!(s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": 0, "y": 0, "handle": 7})).is_err());
}
#[test]
fn mesh_commands_edit_mesh_envelopes() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
let r = s.execute("object.mesh.addLine", &json!({"id": e.0, "x": 150, "y": 175})).unwrap();
let (rows, cols, points, handles) = mesh(&s, e);
assert_eq!((rows, cols, handles.len()), (2, 2, 9));
let i = r["index"].as_u64().unwrap() as usize;
assert!(points[i].distance(Point::new(150.0, 175.0)) < 0.5);
s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": i, "x": 160, "y": 160})).unwrap();
assert!(mesh(&s, e).2[i].distance(Point::new(160.0, 160.0)) < 1e-9);
s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": i, "x": 200, "y": 160, "handle": 0})).unwrap();
assert!((mesh(&s, e).3[i][0] - Vec2::new(40.0, 0.0)).hypot() < 1e-9);
s.execute("object.mesh.deletePoint", &json!({"id": e.0, "index": i})).unwrap();
assert_eq!(mesh(&s, e).0, 1);
assert!(s.execute("object.mesh.setPointColor", &json!({"id": e.0, "index": 0, "color": "#ff0000"})).is_err(), "envelope points have no colour");
// One undo step per edit.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(mesh(&s, e).0, 2);
}
#[test]
fn mesh_handles_turn_with_the_envelope_and_round_trip() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 0, "x": 150, "y": 100, "handle": 0})).unwrap();
s.execute("object.rotate", &json!({"angle": 90, "origin": [200, 150]})).unwrap();
let h = mesh(&s, e).3[0][0];
let want = Affine::rotate(-std::f64::consts::FRAC_PI_2) * Point::new(50.0, 0.0);
assert!((h - want.to_vec2()).hypot() < 1e-9, "{h:?}");
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(back.node(e), d.node(e));
// Mesh envelopes saved before handles existed load as smooth meshes.
let mut old = serde_json::to_value(node(&s, e)).unwrap();
old["kind"]["kind"].as_object_mut().unwrap().remove("handles");
let n: Node = serde_json::from_value(old).unwrap();
assert!(matches!(n.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { handles, .. }, .. } if handles.is_empty()));
}
#[test]
fn reset_with_mesh_keeps_the_warp_shape_along_the_grid() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 60}));
let warp = EnvelopeMap::of(&node(&s, e)).unwrap().surface_mesh(2, 2, vectorcraft_color::Color::BLACK);
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2})).unwrap();
let (_, _, points, handles) = mesh(&s, e);
for (q, (p, h)) in warp.points.iter().zip(points.iter().zip(&handles)) {
assert!(q.p.distance(*p) < 1e-9 && q.handles == *h);
}
}
+351
View File
@@ -0,0 +1,351 @@
//! The Liquify tools: their options (Brush Affects, Simplify, Use Pressure Pen, Show Brush Size),
//! Alt-drag brush sizing, holding still, what they leave alone and incremental strokes.
use serde_json::{Value, json};
use vectorcraft_geom::{PathData, Point};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn path(s: &Session, id: NodeId) -> PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
/// Down at the first point, drags through the middle ones, up at the last.
fn stroke(s: &mut Session, pts: &[(f64, f64)], pressure: f32) {
let v = ViewInfo::default();
for (i, &(x, y)) in pts.iter().enumerate() {
let kind = match i {
0 => PointerKind::Down,
_ if i + 1 == pts.len() => PointerKind::Up,
_ => PointerKind::Drag,
};
s.pointer(&PointerEvent { pressure, ..ev(kind, x, y) }, v).unwrap();
}
}
fn last_journal(s: &Session) -> (String, Value) {
s.journal.last().unwrap().clone()
}
#[test]
fn brush_affects_reach_the_command() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
s.select_tool("scallop", ViewInfo::default()).unwrap();
s.set_tool_option("affectAnchors", &json!(false));
s.set_tool_option("affectOut", &json!(false));
stroke(&mut s, &[(300.0, 150.0), (300.0, 200.0), (300.0, 250.0)], 1.0);
let (cmd, p) = last_journal(&s);
assert_eq!((cmd.as_str(), &p["affectAnchors"], &p["affectIn"], &p["affectOut"]), ("object.liquify", &json!(false), &json!(true), &json!(false)));
// Replaying it gives the same path, and the boxes change the result.
let mut t = session();
let b = rect(&mut t, 100.0, 100.0, 200.0, 200.0);
t.execute("select.set", &json!({"ids": [b.0]})).unwrap();
t.execute(&cmd, &p).unwrap();
assert_eq!(path(&t, b), path(&s, a));
let mut all = p.clone();
for k in ["affectAnchors", "affectIn", "affectOut"] {
all[k] = json!(true);
}
t.execute("edit.undo", &json!({})).unwrap();
t.execute(&cmd, &all).unwrap();
assert_ne!(path(&t, b), path(&s, a));
}
#[test]
fn simplify_can_be_turned_off() {
let run = |on: bool| {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
// Pucker centred on an edge pulls its new anchors along the edge: they stay flat.
let p = json!({"tool": "pucker", "points": [[300, 200]], "intensity": 1, "detail": 10, "simplify": 100, "simplifyOn": on});
s.execute("object.liquify", &p).unwrap();
path(&s, a).anchor_count()
};
assert!(run(false) > run(true), "{} vs {}", run(false), run(true));
}
#[test]
fn pen_pressure_is_the_intensity_with_use_pressure_pen() {
let run = |pressures: [f64; 2], use_pressure: bool| {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
let pts = json!([[260, 150, pressures[0]], [260, 250, pressures[1]]]);
s.execute("object.liquify", &json!({"tool": "pucker", "points": pts, "width": 120, "height": 120, "usePressure": use_pressure})).unwrap();
path(&s, a)
};
let square = {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
path(&s, a)
};
// No pressure: nothing moves; more pressure moves more; pressure is ignored without the option.
assert_eq!(run([0.0, 0.0], true), square);
let x = |p: &PathData| p.anchors().map(|(_, _, an)| an.p.x).fold(f64::MAX, |m, v| if v > 200.0 { m.min(v) } else { m });
assert!(x(&run([1.0, 1.0], true)) < x(&run([0.3, 0.3], true)));
assert_eq!(run([0.0, 0.0], false), run([1.0, 1.0], false));
// The tool sends each sample's pressure while the option is on.
let mut s = session();
rect(&mut s, 100.0, 100.0, 200.0, 200.0);
s.select_tool("bloat", ViewInfo::default()).unwrap();
stroke(&mut s, &[(300.0, 150.0), (300.0, 200.0), (300.0, 210.0)], 0.4);
assert_eq!(last_journal(&s).1["points"][0].as_array().unwrap().len(), 2);
s.set_tool_option("usePressure", &json!(true));
stroke(&mut s, &[(300.0, 150.0), (300.0, 200.0), (300.0, 210.0)], 0.4);
let (_, p) = last_journal(&s);
assert_eq!((p["usePressure"].as_bool(), p["points"][1][2].as_f64().map(|f| (f * 1e6).round() / 1e6)), (Some(true), Some(0.4)));
}
#[test]
fn alt_drag_sizes_the_brush_from_its_size() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("warp", v).unwrap();
s.set_tool_option("height", &json!(60));
let alt = Mods { alt: true, ..Mods::default() };
let shift_alt = Mods { shift: true, ..alt };
let size = |s: &Session| (s.tool_options()["width"].as_f64().unwrap(), s.tool_options()["height"].as_f64().unwrap());
s.pointer(&ev(PointerKind::Down, 50.0, 50.0).with_mods(alt), v).unwrap();
// The first sample barely moves: the brush stays about as it was (it used to collapse to 1 pt).
s.pointer(&ev(PointerKind::Drag, 51.0, 50.0).with_mods(alt), v).unwrap();
assert_eq!(size(&s), (102.0, 60.0));
s.pointer(&ev(PointerKind::Drag, 60.0, 45.0).with_mods(alt), v).unwrap();
assert_eq!(size(&s), (120.0, 50.0));
s.pointer(&ev(PointerKind::Up, 60.0, 45.0).with_mods(alt), v).unwrap();
assert_eq!(s.journal.iter().filter(|(c, _)| c == "object.liquify").count(), 0, "sizing doesn't liquify");
// Shift keeps the proportions.
s.pointer(&ev(PointerKind::Down, 0.0, 0.0).with_mods(shift_alt), v).unwrap();
s.pointer(&ev(PointerKind::Drag, 60.0, 5.0).with_mods(shift_alt), v).unwrap();
assert_eq!(size(&s), (240.0, 100.0));
s.pointer(&ev(PointerKind::Up, 60.0, 5.0).with_mods(shift_alt), v).unwrap();
// The size is kept: the other Liquify tools share it.
s.select_tool("pucker", v).unwrap();
assert_eq!(size(&s), (240.0, 100.0));
}
#[test]
fn show_brush_size_hides_the_outline() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("twirl", v).unwrap();
s.pointer(&ev(PointerKind::Move, 200.0, 200.0), v).unwrap();
assert_eq!(s.overlays(v).len(), 1);
s.set_tool_option("showBrush", &json!(false));
assert!(s.overlays(v).is_empty());
assert_eq!(s.tool_options()["showBrush"], json!(false));
// Sizing the brush shows it anyway.
s.pointer(&ev(PointerKind::Down, 200.0, 200.0).with_mods(Mods { alt: true, ..Mods::default() }), v).unwrap();
assert_eq!(s.overlays(v).len(), 1);
}
// ---------- holding still ----------
/// Twirl pressed at (300, 200) on the square's right edge, held for `holds` (seconds per tick),
/// released.
fn held_twirl(holds: &[f64]) -> (Session, NodeId) {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
let v = ViewInfo::default();
s.select_tool("twirl", v).unwrap();
s.pointer(&ev(PointerKind::Down, 300.0, 200.0), v).unwrap();
for dt in holds {
assert!(s.tool_wants_ticks());
s.tool_tick(*dt, v).unwrap();
}
s.pointer(&ev(PointerKind::Up, 300.0, 200.0), v).unwrap();
(s, a)
}
#[test]
fn holding_still_keeps_twirling_scaled_by_time() {
let (once, a) = held_twirl(&[]);
let (held, _) = held_twirl(&[0.35]);
let (ticked, _) = held_twirl(&[0.1, 0.1, 0.1, 0.05]);
// A tenth of a second held is one more dab where the brush is: 3 after 0.35 s.
let (_, p) = last_journal(&held);
assert_eq!(p["points"], json!([[300.0, 200.0], [300.0, 200.0], [300.0, 200.0], [300.0, 200.0]]));
assert_eq!(path(&held, a), path(&ticked, a), "the same time held in other ticks");
// Longer holds twirl further: the edge's anchors swing further off x = 300.
let c = Point::new(300.0, 200.0);
let turned =
|s: &Session| path(s, a).anchors().filter(|(_, _, an)| an.p.distance(c) < 50.0).map(|(_, _, an)| (an.p.x - 300.0).abs()).fold(0.0, f64::max);
let (longer, _) = held_twirl(&[1.0]);
assert!(turned(&longer) > turned(&held) && turned(&held) > turned(&once), "{} {} {}", turned(&longer), turned(&held), turned(&once));
// One undo step whose journal entry replays the same.
let mut t = session();
rect(&mut t, 100.0, 100.0, 200.0, 200.0);
t.execute("object.liquify", &p).unwrap();
assert_eq!(path(&t, a), path(&held, a));
}
#[test]
fn only_twirl_pucker_and_bloat_keep_going() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 200.0, 200.0);
let v = ViewInfo::default();
for (tool, holds) in [("warp", false), ("scallop", false), ("crystallize", false), ("wrinkle", false), ("pucker", true), ("bloat", true)] {
s.select_tool(tool, v).unwrap();
assert!(!s.tool_wants_ticks(), "{tool} idle");
s.pointer(&ev(PointerKind::Down, 300.0, 200.0), v).unwrap();
assert_eq!(s.tool_wants_ticks(), holds, "{tool}");
s.tool_tick(0.5, v).unwrap();
s.pointer(&ev(PointerKind::Up, 300.0, 200.0), v).unwrap();
let n = last_journal(&s).1["points"].as_array().unwrap().len();
assert_eq!(n, if holds { 6 } else { 1 }, "{tool}");
}
// Repeated points are no extra dabs for the tools that don't keep going.
let warp = |pts: Value| {
let mut t = session();
let a = rect(&mut t, 100.0, 100.0, 200.0, 200.0);
t.execute("object.liquify", &json!({"tool": "warp", "points": pts})).unwrap();
path(&t, a)
};
assert_eq!(warp(json!([[300, 200], [300, 200], [320, 200]])), warp(json!([[300, 200], [320, 200]])));
}
// ---------- what Liquify leaves alone ----------
fn node_json(s: &Session, id: u64) -> Value {
serde_json::to_value(s.doc().unwrap().doc.node(NodeId(id)).unwrap()).unwrap()
}
#[test]
fn type_symbols_graphs_and_live_contents_are_left_alone_and_reported() {
let mut s = session();
let id = |v: Value| v["id"].as_u64().unwrap();
let text = id(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Liquid"})).unwrap());
let graph = id(s.execute("graph.create", &json!({"type": "column", "x": 300, "y": 60, "width": 120, "height": 80, "rows": [[1, 2]]})).unwrap());
let sq = rect(&mut s, 450.0, 60.0, 60.0, 60.0);
s.execute("select.set", &json!({"ids": [sq.0]})).unwrap();
let inst = id(s.execute("symbol.new", &json!({"name": "Dot"})).unwrap());
let a = rect(&mut s, 100.0, 300.0, 60.0, 60.0);
let b = rect(&mut s, 200.0, 300.0, 60.0, 60.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let blend = id(s.execute("object.blend.make", &json!({"steps": 2})).unwrap());
let c = rect(&mut s, 400.0, 300.0, 80.0, 60.0);
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
let env = id(s.execute("object.envelope.makeWithWarp", &json!({})).unwrap());
let r = rect(&mut s, 560.0, 300.0, 40.0, 40.0);
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let rep = id(s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 60})).unwrap());
let plain = rect(&mut s, 100.0, 450.0, 600.0, 60.0);
let all = [text, graph, inst, blend, env, rep];
let before: Vec<Value> = all.iter().map(|i| node_json(&s, *i)).collect();
// Nothing selected: the brush sweeps the whole drawing.
s.execute("select.set", &json!({"ids": []})).unwrap();
let pts: Vec<Value> = (0..=16).flat_map(|i| [json!([80 + i * 40, 100]), json!([80 + i * 40, 330]), json!([80 + i * 40, 480])]).collect();
let out = s.execute("object.liquify", &json!({"tool": "bloat", "points": pts, "width": 160, "height": 160, "intensity": 1})).unwrap();
for (i, id) in all.iter().enumerate() {
assert_eq!(node_json(&s, *id), before[i], "object {id} unchanged");
}
assert!(out["ids"].as_array().unwrap().contains(&json!(plain.0)));
let mut skipped: Vec<u64> = out["skipped"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
skipped.sort();
let mut want = all.to_vec();
want.sort();
assert_eq!(skipped, want);
let w = out["warning"].as_str().unwrap();
for kind in ["type", "symbols", "graphs", "blends", "envelopes", "repeats"] {
assert!(w.contains(kind), "{kind} in {w}");
}
// Selected, they are left alone too (and a path inside a blend is the blend's).
s.execute("select.set", &json!({"ids": [text, blend, plain.0]})).unwrap();
let out =
s.execute("object.liquify", &json!({"tool": "pucker", "points": [[130, 100], [130, 330], [130, 480]], "width": 200, "height": 200})).unwrap();
assert_eq!(out["ids"], json!([plain.0]));
assert_eq!(node_json(&s, blend), before[3]);
let inside = s.execute("object.liquify", &json!({"tool": "pucker", "ids": [a.0], "points": [[130, 330]]})).unwrap();
assert_eq!((&inside["ids"], &inside["skipped"]), (&json!([]), &json!([blend])));
}
#[test]
fn guides_are_left_alone_in_selection_mode_and_the_tool_reports_skips() {
let mut s = session();
let g = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
let guide = path(&s, g);
let a = rect(&mut s, 120.0, 120.0, 160.0, 160.0);
let t = s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [g.0, a.0, t]})).unwrap();
let v = ViewInfo::default();
s.select_tool("bloat", v).unwrap();
let ui = s.pointer(&ev(PointerKind::Down, 290.0, 200.0), v).unwrap();
assert!(matches!(ui.first(), Some(UiRequest::Status(m)) if m.contains("type")), "{ui:?}");
s.pointer(&ev(PointerKind::Up, 290.0, 200.0), v).unwrap();
assert_eq!(path(&s, g), guide);
assert_ne!(path(&s, a).bounds().unwrap(), vectorcraft_geom::Rect::new(120.0, 120.0, 280.0, 280.0));
}
// ---------- incremental strokes ----------
/// Every prefix of `pts` previewed in one interaction gives what the whole prefix gives at once.
fn previews_match_whole_strokes(params: Value, pts: &[Value], select: bool) {
let setup = || {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
let b = rect(&mut s, 330.0, 120.0, 120.0, 160.0);
if select {
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
} else {
s.execute("select.set", &json!({"ids": []})).unwrap();
}
(s, a, b)
};
let (mut live, a, b) = setup();
live.begin_interaction("Liquify").unwrap();
for k in 1..=pts.len() {
let mut p = params.clone();
p["points"] = json!(pts[..k]);
let r = live.preview("object.liquify", &p).unwrap();
let (mut whole, ..) = setup();
let w = whole.execute("object.liquify", &p).unwrap();
assert_eq!(r["ids"], w["ids"], "prefix {k}");
assert_eq!((path(&live, a), path(&live, b)), (path(&whole, a), path(&whole, b)), "prefix {k} of {params}");
}
assert!(live.liquify_stroke.is_some(), "the drag goes on from the last preview");
live.commit_interaction().unwrap();
}
#[test]
fn a_stroke_applied_as_it_grows_equals_the_whole_stroke() {
let path_pts: Vec<Value> = (0..14).map(|i| json!([250 + i * 12, 130 + (i * 37) % 90])).collect();
previews_match_whole_strokes(json!({"tool": "warp", "width": 70, "height": 50, "angle": 20, "intensity": 0.8}), &path_pts, false);
previews_match_whole_strokes(json!({"tool": "crystallize", "width": 60, "height": 60, "complexity": 4}), &path_pts, true);
// Holds (repeated points) and pressure.
let mut held: Vec<Value> = path_pts.iter().take(5).cloned().collect();
held.extend([json!([298, 200, 0.4]), json!([298, 200, 0.4]), json!([298, 200, 0.9]), json!([310, 210, 1])]);
previews_match_whole_strokes(json!({"tool": "pucker", "usePressure": true}), &held, false);
previews_match_whole_strokes(json!({"tool": "twirl", "rate": 120}), &held, true);
}
#[test]
fn a_changed_brush_or_another_stroke_starts_afresh() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
s.begin_interaction("Liquify").unwrap();
s.preview("object.liquify", &json!({"tool": "bloat", "points": [[300, 150], [300, 200]]})).unwrap();
// Not a continuation (other points, another size): applied to the snapshot, as a fresh call is.
s.preview("object.liquify", &json!({"tool": "bloat", "points": [[300, 250]], "width": 60})).unwrap();
let mut t = session();
rect(&mut t, 100.0, 100.0, 200.0, 200.0);
t.execute("object.liquify", &json!({"tool": "bloat", "points": [[300, 250]], "width": 60})).unwrap();
assert_eq!(path(&s, a), path(&t, a));
}
+4 -2
View File
@@ -399,8 +399,10 @@ fn envelope_release_and_expand() {
s.execute("object.envelope.expand", &json!({})).unwrap();
let g = node(&s, e);
assert!(matches!(g.kind, NodeKind::Group { .. }));
let p = g.children().unwrap()[0].path_data().unwrap().anchor_count();
assert!(p > 4);
// (Distort Appearance, on for new envelopes, expands the stroke too: the fill comes first.)
let mut fill = None;
g.walk(&mut |c| fill = fill.or_else(|| c.path_data().map(|p| p.anchor_count())));
assert!(fill.unwrap() > 4);
}
#[test]
+184
View File
@@ -0,0 +1,184 @@
//! Perspective planes: offsets, plane widgets (alone, Shift with the objects, Alt with copies),
//! Move Plane to Match Object, and grid edits with attached art.
use serde_json::json;
use vectorcraft_geom::{Point, Vec2};
use vectorcraft_tools::Mods;
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
use super::tests_persp_select::{attached, drag, grid, persp_session, plane_pts, undo_len};
use super::*;
fn path(s: &Session, id: NodeId) -> vectorcraft_geom::PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
#[test]
fn moving_a_plane_alone_leaves_its_objects() {
let (mut s, id) = persp_session();
let before = path(&s, id);
let n = undo_len(&s);
let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 50})).unwrap();
assert_eq!((r["plane"].as_str(), r["offset"].as_f64()), (Some("right"), Some(50.0)));
assert_eq!(undo_len(&s), n + 1);
let g = grid(&s);
assert_eq!(g.right_offset, 50.0);
// The plane is drawn where it was moved: its origin corner lies 50 pt along its normal.
let o = g.homography(Plane::Right).unwrap().apply(Point::ZERO).unwrap();
assert!((o - g.homography_at(Plane::Right, 50.0).unwrap().apply(Point::ZERO).unwrap()).hypot() < 1e-9);
assert!((o - Point::new(g.origin[0], g.origin[1])).hypot() > 1.0);
assert_eq!(path(&s, id), before);
assert_eq!(attached(&s, id), Some((Plane::Right, 0.0)));
// Relative moves; new objects attach where the plane is.
s.execute("perspective.plane.move", &json!({"plane": "right", "by": -20})).unwrap();
assert_eq!(grid(&s).right_offset, 30.0);
let r = s.execute("shape.rectangle", &json!({"x": 470, "y": 300, "width": 30, "height": 30})).unwrap();
let b = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [b.0], "plane": "right"})).unwrap();
assert_eq!(attached(&s, b), Some((Plane::Right, 30.0)));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(grid(&s).right_offset, 50.0);
assert!(s.execute("perspective.plane.move", &json!({"plane": "right"})).is_err());
assert!(s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 1, "objects": "all"})).is_err());
assert!(s.execute("perspective.plane.move", &json!({"plane": "right", "offset": -1e9})).is_err(), "behind the viewer");
}
#[test]
fn moving_a_plane_with_its_objects_or_copies() {
let (mut s, id) = persp_session();
// A second object moved off the plane along its normal stays put.
let r = s.execute("shape.rectangle", &json!({"x": 560, "y": 330, "width": 30, "height": 30})).unwrap();
let off = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [off.0], "plane": "right"})).unwrap();
s.execute("perspective.transform", &json!({"ids": [off.0], "matrix": [1, 0, 0, 1, 0, 0], "depth": 20})).unwrap();
let off_path = path(&s, off);
let q = plane_pts(&s, id, Plane::Right, 0.0);
let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 35, "objects": "move"})).unwrap();
assert_eq!(r["ids"], json!([id.0]));
assert_eq!(attached(&s, id), Some((Plane::Right, 35.0)));
for (a, b) in q.iter().zip(plane_pts(&s, id, Plane::Right, 35.0)) {
assert!((*a - b).hypot() < 1e-6, "keeps its place on the plane");
}
assert_eq!(path(&s, off), off_path);
// Copies: the originals stay, the copies go with the plane.
let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 60, "objects": "copy"})).unwrap();
let copy = NodeId(r["ids"][0].as_u64().unwrap());
assert_ne!(copy, id);
assert_eq!(attached(&s, id), Some((Plane::Right, 35.0)));
assert_eq!(attached(&s, copy), Some((Plane::Right, 60.0)));
for (a, b) in q.iter().zip(plane_pts(&s, copy, Plane::Right, 60.0)) {
assert!((*a - b).hypot() < 1e-6);
}
}
#[test]
fn plane_widgets_drag_with_shift_and_alt() {
let (mut s, id) = persp_session();
s.execute("perspective.grid.show", &json!({"visible": true})).unwrap();
for tool in ["perspectiveGrid", "perspectiveSelection"] {
s.select_tool(tool, ViewInfo::default()).unwrap();
let g = grid(&s);
let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap();
let from = g.offset(Plane::Right);
// Where the widget lands 15 pt further along the plane's normal.
let to = g
.homography_at(Plane::Right, from + 15.0)
.unwrap()
.apply(g.homography(Plane::Right).unwrap().inverse().unwrap().apply(w).unwrap())
.unwrap();
let n = undo_len(&s);
drag(&mut s, &[w, to, to], Mods { shift: true, ..Default::default() });
assert_eq!(undo_len(&s), n + 1, "{tool}");
let (cmd, p) = s.journal.last().unwrap().clone();
assert_eq!((cmd.as_str(), p["objects"].as_str()), ("perspective.plane.move", Some("move")));
assert!((grid(&s).right_offset - from - 15.0).abs() < 1e-6, "{tool}: {}", grid(&s).right_offset);
assert!((attached(&s, id).unwrap().1 - from - 15.0).abs() < 1e-6, "{tool}: the object went along");
}
// Alt copies the objects; a plain drag moves the plane alone.
let g = grid(&s);
let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap();
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len();
let k = count(&s);
drag(&mut s, &[w, w + Vec2::new(-12.0, 0.0), w + Vec2::new(-12.0, 0.0)], Mods { alt: true, ..Default::default() });
assert_eq!(count(&s), k + 1);
let g = grid(&s);
let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap();
let depth = attached(&s, id).unwrap().1;
drag(&mut s, &[w, w + Vec2::new(-12.0, 0.0), w + Vec2::new(-12.0, 0.0)], Mods::default());
assert_eq!(attached(&s, id).unwrap().1, depth);
assert_eq!(count(&s), k + 1);
}
#[test]
fn double_clicking_a_plane_widget_asks_for_its_options() {
let (mut s, _) = persp_session();
s.execute("perspective.grid.show", &json!({"visible": true})).unwrap();
let v = ViewInfo::default();
let (_, w) = grid(&s).plane_widgets().into_iter().find(|(p, _)| *p == Plane::Ground).unwrap();
for tool in ["perspectiveGrid", "perspectiveSelection"] {
s.select_tool(tool, v).unwrap();
let r = s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::DoubleClick, w.x, w.y), v).unwrap();
assert_eq!(r, vec![crate::UiRequest::Dialog("perspectivePlane".into(), json!({"plane": "ground"}))], "{tool}");
}
}
#[test]
fn move_plane_to_match_object() {
let (mut s, id) = persp_session();
let q0 = plane_pts(&s, id, Plane::Right, 0.0);
s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": -25})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap();
let r = s.execute("perspective.plane.matchObject", &json!({})).unwrap();
assert_eq!(r, json!({"plane": "right", "offset": -25.0}));
let g = grid(&s);
assert_eq!((g.right_offset, g.plane), (-25.0, Plane::Right));
// The object now lies on the plane where it is drawn.
let h = g.homography(Plane::Right).unwrap();
for (q, a) in q0.iter().zip(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors()) {
assert!((h.apply(*q).unwrap() - a.2.p).hypot() < 1e-6);
}
s.execute("perspective.release", &json!({"ids": [id.0]})).unwrap();
assert!(s.execute("perspective.plane.matchObject", &json!({"id": id.0})).is_err());
}
#[test]
fn grid_edits_keep_attached_art_in_place_unless_reprojected() {
let (mut s, id) = persp_session();
let before = path(&s, id);
let q = plane_pts(&s, id, Plane::Right, 0.0);
let vr = grid(&s).vp_right;
s.execute("perspective.grid.set", &json!({"vpRight": vr + 150.0})).unwrap();
assert_eq!(path(&s, id), before, "as in the reference app, art stays where it is");
s.execute("perspective.grid.set", &json!({"vpRight": vr - 100.0, "reproject": true})).unwrap();
assert_ne!(path(&s, id), before);
// Re-projected from the previous grid: the same place on the plane of the new one.
let old = {
let mut g = grid(&s);
g.vp_right = vr + 150.0;
g
};
let hi = old.homography(Plane::Right).unwrap().inverse().unwrap();
let back: Vec<Point> = before.anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect();
for (a, b) in back.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
assert!((*a - b).hypot() < 1e-6, "{a:?} {b:?}");
}
assert!(q.len() == back.len());
}
#[test]
fn plane_offsets_round_trip_and_old_grids_have_none() {
let (mut s, _) = persp_session();
s.execute("perspective.grid.set", &json!({"leftOffset": 12, "groundOffset": -8})).unwrap();
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let g = PerspectiveGrid::from_doc(&doc).unwrap();
assert_eq!((g.left_offset, g.right_offset, g.ground_offset), (12.0, 0.0, -8.0));
let mut stored = doc.unknown["perspectiveGrid"].clone();
assert!(stored.get("rightOffset").is_none(), "zero offsets aren't written");
for k in ["leftOffset", "groundOffset"] {
stored.as_object_mut().unwrap().remove(k);
}
let old: PerspectiveGrid = serde_json::from_value(stored).unwrap();
assert_eq!((old.left_offset, old.ground_offset), (0.0, 0.0));
}
+255
View File
@@ -0,0 +1,255 @@
//! Perspective Selection tool: scaling in perspective, Alt-drag copies, moving perpendicular to the
//! plane (5), Transform Again and arrow nudges in perspective.
use serde_json::json;
use vectorcraft_geom::{Point, Rect, Vec2};
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
/// An 800 × 600 document with a two-point grid and a rectangle attached to the right plane.
pub(crate) fn persp_session() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
// Snap to Grid (on by default) would land moved edges on gridlines: these tests measure exact moves.
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
(s, id)
}
pub(crate) fn grid(s: &Session) -> PerspectiveGrid {
PerspectiveGrid::from_doc(&s.doc().unwrap().doc).unwrap()
}
pub(crate) fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Where `id` is attached (plane, depth).
pub(crate) fn attached(s: &Session, id: NodeId) -> Option<(Plane, f64)> {
grid(s).attachment_of(&s.doc().unwrap().doc, id)
}
/// The anchors of path `id` in the plane coordinates of `plane` at `depth`.
pub(crate) fn plane_pts(s: &Session, id: NodeId, plane: Plane, depth: f64) -> Vec<Point> {
let g = grid(s);
let hi = g.homography_at(plane, depth).unwrap().inverse().unwrap();
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect()
}
pub(crate) fn bounds_of(pts: &[Point]) -> Rect {
pts.iter().skip(1).fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p))
}
/// Pointer events with the active tool: down at the first point, drags, up at the last.
pub(crate) fn drag(s: &mut Session, pts: &[Point], mods: Mods) {
let v = ViewInfo::default();
for (i, p) in pts.iter().enumerate() {
let kind = if i == 0 {
PointerKind::Down
} else if i == pts.len() - 1 {
PointerKind::Up
} else {
PointerKind::Drag
};
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(mods), v).unwrap();
}
}
#[test]
fn handles_scale_in_perspective_about_the_opposite_handle() {
let (mut s, id) = persp_session();
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
let g = grid(&s);
let before = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0));
// The box's handles are drawn in perspective: the far corner of the plane-space box.
let h = g.homography(Plane::Right).unwrap();
let corner = h.apply(Point::new(before.x1, before.y1)).unwrap();
let anchors = s.overlays(ViewInfo::default()).into_iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Anchor { .. })).count();
assert!(anchors >= 8, "{anchors} handles");
// Drag that handle to twice the size in plane space.
let target = h.apply(Point::new(before.x0 + 2.0 * before.width(), before.y0 + 2.0 * before.height())).unwrap();
let n = undo_len(&s);
drag(&mut s, &[corner, target, target], Mods::default());
assert_eq!(undo_len(&s), n + 1, "one undo step");
assert_eq!(s.journal.last().unwrap().0, "perspective.transform");
let after = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0));
assert!((after.x0 - before.x0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "the opposite corner stays: {before:?} → {after:?}");
assert!((after.width() - 2.0 * before.width()).abs() < 1e-6 && (after.height() - 2.0 * before.height()).abs() < 1e-6, "{after:?}");
// Still a rectangle on the plane.
for q in plane_pts(&s, id, Plane::Right, 0.0) {
assert!(((q.x - after.x0).abs() < 1e-6 || (q.x - after.x1).abs() < 1e-6) && ((q.y - after.y0).abs() < 1e-6 || (q.y - after.y1).abs() < 1e-6));
}
assert_eq!(attached(&s, id), Some((Plane::Right, 0.0)));
}
#[test]
fn alt_drag_copies_and_the_copy_stays_attached() {
let (mut s, id) = persp_session();
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
let before = plane_pts(&s, id, Plane::Right, 0.0);
let c = bounds_of(&before).center();
let h = grid(&s).homography(Plane::Right).unwrap();
let (from, to) = (h.apply(c).unwrap(), h.apply(c + Vec2::new(80.0, 0.0)).unwrap());
let n = undo_len(&s);
drag(&mut s, &[from, to, to], Mods { alt: true, ..Default::default() });
assert_eq!(undo_len(&s), n + 1);
let copy = *s.doc().unwrap().selection.objects.first().unwrap();
assert_ne!(copy, id, "the copy is selected");
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before, "the original stays");
assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0)), "the copy is attached");
for (a, b) in before.iter().zip(plane_pts(&s, copy, Plane::Right, 0.0)) {
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6);
}
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(copy).is_none());
}
#[test]
fn pressing_5_while_dragging_moves_perpendicular_to_the_plane() {
let (mut s, id) = persp_session();
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
let before = plane_pts(&s, id, Plane::Right, 0.0);
let c = bounds_of(&before).center();
let g = grid(&s);
let from = g.homography(Plane::Right).unwrap().apply(c).unwrap();
// The point 40 pt in front of the plane (along its normal, toward the viewer's left wall side).
let to = g.homography_at(Plane::Right, 40.0).unwrap().apply(c).unwrap();
let v = ViewInfo::default();
let n = undo_len(&s);
s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap();
s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap();
assert_eq!(undo_len(&s), n + 1);
let (plane, depth) = attached(&s, id).unwrap();
assert_eq!(plane, Plane::Right);
assert!((depth - 40.0).abs() < 1e-6, "{depth}");
// Same place within the plane, on the parallel plane 40 pt along the normal.
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 40.0)) {
assert!((*a - b).hypot() < 1e-6, "{a:?} {b:?}");
}
// Pressing 5 again goes back to moving within the plane.
let c2 = g.homography_at(Plane::Right, 40.0).unwrap().apply(c).unwrap();
let to2 = g.homography_at(Plane::Right, 40.0).unwrap().apply(c + Vec2::new(30.0, 0.0)).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, c2.x, c2.y), v).unwrap();
s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap();
s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, to2.x, to2.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, to2.x, to2.y), v).unwrap();
assert!((attached(&s, id).unwrap().1 - 40.0).abs() < 1e-6);
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 40.0)) {
assert!((b - *a - Vec2::new(30.0, 0.0)).hypot() < 1e-6);
}
}
#[test]
fn transform_again_repeats_the_last_perspective_move() {
let (mut s, id) = persp_session();
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
let before = plane_pts(&s, id, Plane::Right, 0.0);
let c = bounds_of(&before).center();
let h = grid(&s).homography(Plane::Right).unwrap();
let (from, to) = (h.apply(c).unwrap(), h.apply(c + Vec2::new(25.0, 10.0)).unwrap());
drag(&mut s, &[from, to, to], Mods::default());
// Twice the plane-space move, not twice the page move (which foreshortening would distort).
s.execute("object.transformAgain", &json!({})).unwrap();
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
assert!((b - *a - Vec2::new(50.0, 20.0)).hypot() < 1e-6, "{a:?} → {b:?}");
}
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Transform in Perspective");
// An ordinary move takes over Transform Again.
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
let p0 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
s.execute("object.transformAgain", &json!({})).unwrap();
let p1 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert!((p1.x0 - p0.x0 - 5.0).abs() < 1e-9 && (p1.y0 - p0.y0).abs() < 1e-9);
// A cancelled drag records nothing.
s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 7, 0]})).unwrap();
let v = ViewInfo::default();
let c = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center();
s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 20.0, c.y), v).unwrap();
assert!(s.in_interaction());
s.cancel_interaction().unwrap();
assert_eq!(s.doc().unwrap().last_perspective.as_ref().unwrap()["matrix"][4], json!(7.0));
}
#[test]
fn transform_again_repeats_a_perspective_scale_and_copies() {
let (mut s, id) = persp_session();
let before = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0));
let r = s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 100, 0], "copy": true})).unwrap();
let copy = NodeId(r["ids"][0].as_u64().unwrap());
s.execute("object.transformAgain", &json!({})).unwrap();
let third = *s.doc().unwrap().selection.objects.first().unwrap();
assert!(third != copy && third != id);
let b = bounds_of(&plane_pts(&s, third, Plane::Right, 0.0));
assert!((b.x0 - before.x0 - 200.0).abs() < 1e-6, "{before:?} {b:?}");
assert_eq!(attached(&s, third), Some((Plane::Right, 0.0)));
assert!(s.execute("perspective.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).is_err());
assert!(s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": f64::MAX})).is_err());
}
#[test]
fn arrow_keys_nudge_in_perspective() {
let (mut s, id) = persp_session();
let v = ViewInfo::default();
s.select_tool("perspectiveSelection", v).unwrap();
assert!(s.tool_claims_key(ToolKey::Right, v), "the tool takes the arrows for a selection in perspective");
let before = plane_pts(&s, id, Plane::Right, 0.0);
let n = undo_len(&s);
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
assert_eq!(undo_len(&s), n + 1);
let after = plane_pts(&s, id, Plane::Right, 0.0);
let d = after[0] - before[0];
for (a, b) in before.iter().zip(&after) {
assert!((*b - *a - d).hypot() < 1e-6, "moves within the plane");
}
// The page step at the centre is the keyboard increment.
let h = grid(&s).homography(Plane::Right).unwrap();
let c = bounds_of(&before).center();
let step = h.apply(c + d).unwrap() - h.apply(c).unwrap();
assert!((step - Vec2::new(s.prefs.keyboard_increment, 0.0)).hypot() < 1e-6, "{step:?}");
// Alt copies; Shift takes ten steps.
s.tool_key(ToolKey::Left, Mods { alt: true, shift: true, ..Default::default() }, v).unwrap();
let copy = *s.doc().unwrap().selection.objects.first().unwrap();
assert_ne!(copy, id);
assert_eq!(attached(&s, copy).map(|a| a.0), Some(Plane::Right));
// Nothing attached: the arrows nudge as usual.
s.execute("perspective.release", &json!({"ids": [id.0, copy.0]})).unwrap();
assert!(!s.tool_claims_key(ToolKey::Right, v));
assert!(s.execute("perspective.nudge", &json!({"dx": 1, "dy": 0})).is_err());
assert!(s.execute("perspective.nudge", &json!({"dx": 0, "dy": 0})).is_err());
}
#[test]
fn selecting_an_attached_object_switches_to_its_plane() {
let (mut s, id) = persp_session();
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
let c = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center();
drag(&mut s, &[c, c], Mods::default());
assert_eq!(grid(&s).plane, Plane::Right);
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
// A click is no edit.
assert_eq!(persp::attachment(s.doc().unwrap().doc.node(id).unwrap()), Some((Plane::Right, 0.0)));
}
#[test]
fn attachments_round_trip_through_native_files() {
let (mut s, id) = persp_session();
s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": 12.5})).unwrap();
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(persp::attachment(doc.node(id).unwrap()), Some((Plane::Right, 12.5)));
assert_eq!(*doc.node(id).unwrap(), *s.doc().unwrap().doc.node(id).unwrap());
// Objects outside perspective write nothing for it.
let v = serde_json::to_value(vectorcraft_doc::Node::new(NodeId(1), vectorcraft_doc::NodeKind::Group { children: vec![], clip: false })).unwrap();
assert!(v.get("perspective").is_none());
}
+271
View File
@@ -0,0 +1,271 @@
//! Type and symbols in perspective (projected, still type and symbol instances, Edit Text),
//! attachment bookkeeping (copy, paste, duplicate, Selection-tool moves, release) and drawing in
//! perspective (flare, spiral, polar grid, click-to-size dialogs).
use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::Mods;
use vectorcraft_tools::distort::perspective::{self as persp, Plane};
use super::tests_persp_select::{attached, drag, grid, persp_session, undo_len};
use super::*;
/// Point type "HELLO" (48 pt) at (430, 400), attached to the right plane.
fn persp_text(s: &mut Session) -> NodeId {
let r = s.execute("text.create", &json!({"x": 430, "y": 400, "text": "HELLO", "size": 48})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap();
id
}
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
/// The art the renderer and exporters draw for `id` (its outlines, projected).
fn drawn_bounds(s: &Session, id: NodeId) -> Rect {
let n = node(s, id);
vectorcraft_render::effects::reshape(&n, None).unwrap().geometric_bounds().unwrap()
}
#[test]
fn type_in_perspective_is_projected_and_stays_type() {
let (mut s, _) = persp_session();
let id = persp_text(&mut s);
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Text(_)), "still type");
assert_eq!(attached(&s, id), Some((Plane::Right, 0.0)));
let h = n.projection().expect("drawn through a projection");
// Really foreshortened: the right plane recedes to the right, so the letters' far end is
// shorter than their near end.
let out = vectorcraft_render::effects::reshape(&n, None).unwrap();
let mut pts = vec![];
out.walk(&mut |c| {
if let Some(p) = c.path_data() {
pts.extend(p.anchors().map(|(_, _, a)| a.p));
}
});
let b = drawn_bounds(&s, id);
let near: Vec<&Point> = pts.iter().filter(|p| p.x < b.x0 + 0.2 * b.width()).collect();
let far: Vec<&Point> = pts.iter().filter(|p| p.x > b.x1 - 0.2 * b.width()).collect();
let span = |v: &[&Point]| v.iter().map(|p| p.y).fold(f64::MIN, f64::max) - v.iter().map(|p| p.y).fold(f64::MAX, f64::min);
assert!(span(&far) < span(&near) * 0.97, "far {} near {}", span(&far), span(&near));
// Bounds, hit testing and the selection box follow the drawn type.
let gb = n.geometric_bounds().unwrap();
assert!((gb.x0 - b.x0).abs() < 12.0 && (gb.x1 - b.x1).abs() < 12.0, "{gb:?} {b:?}");
let hit = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, b.center(), Default::default()).unwrap();
assert_eq!(hit.leaf, id);
let _ = h;
}
#[test]
fn type_in_perspective_exports_projected_outlines() {
let (mut s, _) = persp_session();
let id = persp_text(&mut s);
let b = drawn_bounds(&s, id);
let doc = s.doc().unwrap().doc.clone();
let svg = vectorcraft_svg::export(&doc, &Default::default());
assert!(!svg.contains("HELLO"), "no flat text in the SVG");
let pdf = vectorcraft_render::effects::bake_document(&doc).unwrap();
let baked = pdf.node(id).unwrap();
assert!(!matches!(baked.kind, NodeKind::Text(_)));
let bb = baked.geometric_bounds().unwrap();
assert!((bb.x0 - b.x0).abs() < 1e-6 && (bb.y1 - b.y1).abs() < 1e-6, "{bb:?} {b:?}");
}
#[test]
fn moving_type_in_perspective_moves_its_projection() {
let (mut s, rect) = persp_session();
let id = persp_text(&mut s);
let NodeKind::Text(t) = &node(&s, id).kind else { panic!("type") };
let flat = t.xf;
s.execute("perspective.transform", &json!({"ids": [id.0], "matrix": [1, 0, 0, 1, 40, 0]})).unwrap();
let n = node(&s, id);
let NodeKind::Text(t) = &n.kind else { panic!() };
assert_eq!(t.xf, flat, "the type itself is untouched");
// Its drawn corner moved 40 pt along the plane.
let g = grid(&s);
let hi = g.homography(Plane::Right).unwrap().inverse().unwrap();
let b0 = {
s.execute("edit.undo", &json!({})).unwrap();
drawn_bounds(&s, id)
};
s.execute("edit.redo", &json!({})).unwrap();
let b1 = drawn_bounds(&s, id);
let d = hi.apply(Point::new(b1.x0, b1.y1)).unwrap() - hi.apply(Point::new(b0.x0, b0.y1)).unwrap();
assert!((d.x - 40.0).abs() < 1.0 && d.y.abs() < 1.0, "{d:?}");
let _ = rect;
}
#[test]
fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() {
let (mut s, _) = persp_session();
let id = persp_text(&mut s);
let drawn = node(&s, id).geometric_bounds().unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("perspective.editText", &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.isolation, Some(id));
let n = node(&s, id);
assert!(n.projection().is_none(), "flat while editing");
// Shown where it is drawn in perspective.
assert!(n.geometric_bounds().unwrap().center().distance(drawn.center()) < 1e-6);
s.execute("text.setText", &json!({"id": id.0, "text": "HELLO WORLD"})).unwrap();
s.execute("object.exitIsolation", &json!({})).unwrap();
let n = node(&s, id);
assert!(n.projection().is_some(), "projected again");
// Nothing left flat anywhere in the history.
for e in &s.doc().unwrap().history.undo {
if let Some(p) = e.doc.node(id).and_then(|n| n.perspective.as_deref()) {
assert!(!p.editing);
}
}
// Not type in perspective: refused.
let (mut s2, rect) = persp_session();
assert!(s2.execute("perspective.editText", &json!({"id": rect.0})).is_err());
}
#[test]
fn symbols_in_perspective_stay_instances() {
let (mut s, rect) = persp_session();
s.execute("perspective.release", &json!({"ids": [rect.0]})).unwrap();
s.execute("select.set", &json!({"ids": [rect.0]})).unwrap();
let sym = s.execute("symbol.new", &json!({"name": "Box"})).unwrap();
let _ = sym;
let r = s.execute("symbol.place", &json!({"name": "Box", "x": 470, "y": 380})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap();
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::SymbolInstance { .. }));
assert!(n.projection().is_some());
let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap();
assert!(!matches!(baked.node(id).unwrap().kind, NodeKind::SymbolInstance { .. }), "exports draw the projected art");
}
#[test]
fn copies_pastes_and_duplicates_stay_attached() {
let (mut s, id) = persp_session();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let pasted = *s.doc().unwrap().selection.objects.first().unwrap();
assert_ne!(pasted, id);
assert_eq!(attached(&s, pasted), Some((Plane::Right, 0.0)));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 0], "copy": true})).unwrap();
let copy = *s.doc().unwrap().selection.objects.first().unwrap();
assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0)));
// Deleting leaves nothing stale behind.
s.execute("edit.clear", &json!({})).unwrap();
assert!(grid(&s).attached_plane(copy).is_none());
}
#[test]
fn a_selection_tool_move_keeps_the_attachment_and_type_follows() {
let (mut s, rect) = persp_session();
let id = persp_text(&mut s);
let before = drawn_bounds(&s, id);
s.execute("select.set", &json!({"ids": [id.0, rect.0]})).unwrap();
let c = node(&s, rect).geometric_bounds().unwrap().center();
s.select_tool("selection", ViewInfo::default()).unwrap();
drag(&mut s, &[c, Point::new(c.x + 25.0, c.y + 10.0), Point::new(c.x + 25.0, c.y + 10.0)], Mods::default());
assert_eq!(attached(&s, rect), Some((Plane::Right, 0.0)));
assert_eq!(attached(&s, id), Some((Plane::Right, 0.0)));
let after = drawn_bounds(&s, id);
assert!((after.x0 - before.x0 - 25.0).abs() < 1e-6 && (after.y0 - before.y0 - 10.0).abs() < 1e-6, "{before:?} {after:?}");
}
#[test]
fn release_keeps_the_look_of_type_in_perspective() {
let (mut s, _) = persp_session();
let id = persp_text(&mut s);
let b = drawn_bounds(&s, id);
s.execute("perspective.release", &json!({"ids": [id.0]})).unwrap();
assert_eq!(attached(&s, id), None);
assert_eq!(drawn_bounds(&s, id), b);
assert!(persp::attachment(&node(&s, id)).is_none());
}
#[test]
fn flare_spiral_and_polar_grid_draw_in_perspective() {
let (mut s, _) = persp_session();
s.execute("perspective.grid.show", &json!({"visible": true})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
for (tool, pts) in [("spiral", [(300.0, 380.0), (320.0, 390.0), (330.0, 400.0)]), ("polarGrid", [(250.0, 350.0), (280.0, 380.0), (300.0, 410.0)])]
{
let n = undo_len(&s);
s.select_tool(tool, ViewInfo::default()).unwrap();
drag(&mut s, &pts.map(|(x, y)| Point::new(x, y)), Mods::default());
assert_eq!(undo_len(&s), n + 1, "{tool}");
assert_eq!(s.journal.last().unwrap().0, "perspective.draw", "{tool}");
let id = *s.doc().unwrap().selection.objects.first().unwrap();
assert_eq!(attached(&s, id).map(|a| a.0), Some(Plane::Left), "{tool}");
}
let r = s.execute("perspective.draw", &json!({"command": "shape.flare", "params": {"cx": 300, "cy": 380, "diameter": 40}})).unwrap();
assert_eq!(attached(&s, NodeId(r["id"].as_u64().unwrap())).map(|a| a.0), Some(Plane::Left));
}
#[test]
fn click_to_size_dialogs_draw_on_the_active_plane_in_plane_units() {
let (mut s, _) = persp_session();
s.execute("perspective.grid.show", &json!({"visible": true})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "right"})).unwrap();
let at = Point::new(470.0, 300.0);
let (cmd, p) = crate::perspective_click(&s, "shape.rectangle", &json!({"x": at.x, "y": at.y, "width": 50, "height": 30}), at).unwrap();
assert_eq!(cmd, "perspective.draw");
let r = s.execute(&cmd, &p).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let g = grid(&s);
let hi = g.homography(Plane::Right).unwrap().inverse().unwrap();
let q: Vec<Point> = node(&s, id).path_data().unwrap().anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect();
let b = q.iter().skip(1).fold(Rect::from_points(q[0], q[0]), |r, p| r.union_pt(*p));
assert!((b.width() - 50.0).abs() < 1e-6 && (b.height() - 30.0).abs() < 1e-6, "{b:?}");
assert!(q.iter().any(|p| (g.homography(Plane::Right).unwrap().apply(*p).unwrap() - at).hypot() < 1e-6), "the click stays a corner");
// Without a shown grid (or with no active plane) nothing changes.
s.execute("perspective.plane.set", &json!({"plane": "none"})).unwrap();
assert!(crate::perspective_click(&s, "shape.rectangle", &json!({}), at).is_none());
}
#[test]
fn the_canvas_draws_type_in_perspective_foreshortened() {
let (mut s, rect) = persp_session();
s.execute("edit.clear", &json!({"ids": [rect.0]})).ok();
let id = persp_text(&mut s);
let b = drawn_bounds(&s, id);
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, b, 2.0, true);
// The inked height of a strip of columns near each end of the line.
let height = |x0: f64, x1: f64| {
let (mut lo, mut hi) = (u32::MAX, 0);
for x in (x0 * img.width as f64) as u32..(x1 * img.width as f64) as u32 {
for y in 0..img.height {
if img.pixel(x, y)[0] < 128 {
lo = lo.min(y);
hi = hi.max(y);
}
}
}
hi.saturating_sub(lo)
};
let (near, far) = (height(0.0, 0.25), height(0.75, 1.0));
assert!(near > 10 && far * 100 < near * 97, "near {near} far {far}");
}
#[test]
fn double_clicking_type_in_perspective_edits_it_and_projections_round_trip() {
let (mut s, _) = persp_session();
let id = persp_text(&mut s);
// Saved and opened again: still type, still projected.
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(doc.node(id).unwrap().projection(), node(&s, id).projection());
let v = ViewInfo::default();
s.select_tool("perspectiveSelection", v).unwrap();
let c = node(&s, id).geometric_bounds().unwrap().center();
let r = s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::DoubleClick, c.x, c.y), v).unwrap();
assert_eq!(r, vec![crate::UiRequest::SwitchTool("type".into())]);
assert_eq!(s.doc().unwrap().isolation, Some(id));
// Saved while editing: the file has it projected.
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert!(doc.node(id).unwrap().projection().is_some());
s.execute("object.exitIsolation", &json!({})).unwrap();
assert!(node(&s, id).projection().is_some());
}
+526
View File
@@ -0,0 +1,526 @@
//! Perspective Grid definition, presets and view options (`perspective.grid.get` / `define`,
//! `perspective.presets.*`, View → Perspective Grid toggles, the grid widgets).
use serde_json::{Value, json};
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Rgb};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn grid(s: &Session) -> PerspectiveGrid {
PerspectiveGrid::effective(&s.doc().unwrap().doc)
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
// ---------- M8.8: Define Grid ----------
#[test]
fn grid_get_reports_the_model_the_define_fields_and_the_station_point() {
let mut s = session();
let r = run(&mut s, "perspective.grid.get", json!({}));
assert_eq!(r["defined"], json!(false), "the default grid until one is set");
assert_eq!(r["grid"]["kind"], json!(2));
assert_eq!(r["define"]["name"], json!("[2P-Normal View]"));
assert_eq!((r["define"]["angle"].clone(), r["define"]["units"].clone()), (json!(45.0), json!("points")));
let g = grid(&s);
assert!(near(r["station"]["x"].as_f64().unwrap(), g.origin[0]), "the normal view looks at the corner");
assert!(near(r["station"]["distance"].as_f64().unwrap(), (g.vp_right - g.vp_left) / 2.0));
// A query: nothing to undo.
assert_eq!(undo_len(&s), 0);
run(&mut s, "perspective.grid.preset", json!({"kind": 1}));
assert_eq!(run(&mut s, "perspective.grid.get", json!({}))["defined"], json!(true));
}
#[test]
fn define_sets_the_grid_from_the_dialog_fields_in_one_undo_step() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let before = grid(&s);
let n = undo_len(&s);
let r = run(
&mut s,
"perspective.grid.define",
json!({"units": "inches", "gridline": 0.5, "angle": 30, "distance": 4, "horizonHeight": 2, "leftColor": "#00ff00", "opacity": 75}),
);
assert_eq!(undo_len(&s), n + 1);
assert_eq!(r["units"], json!("inches"));
let g = grid(&s);
assert!(near(g.cell, 36.0) && near(g.origin[1] - g.horizon, 144.0));
let st = g.station();
assert!(near(st.distance, 288.0) && near(st.x, before.station().x), "the viewer stays put");
assert!(near(g.viewing_angle(), 30.0));
assert_eq!((g.left_color, g.opacity, g.name.as_str()), (Rgb([0, 255, 0]), 75.0, ""), "no longer the preset");
// Unchanged fields leave the grid alone: no undo step.
let same = run(&mut s, "perspective.grid.get", json!({}))["define"].clone();
run(&mut s, "perspective.grid.define", same);
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(grid(&s), before);
// Bad values are refused and change nothing.
for bad in [json!({"angle": 0}), json!({"distance": -1}), json!({"units": "parsecs"}), json!({"kind": 5}), json!({"rightColor": "red"})] {
assert!(s.execute("perspective.grid.define", &bad).is_err(), "{bad}");
}
assert_eq!(grid(&s), before);
}
#[test]
fn define_switches_the_type_around_the_station_point() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let st = grid(&s).station();
run(&mut s, "perspective.grid.define", json!({"kind": 1}));
let g = grid(&s);
assert_eq!(g.kind, 1);
assert!(near(g.vp_left, st.x) && near(g.distance, st.distance), "one-point looks straight at the station's centre of vision");
run(&mut s, "perspective.grid.define", json!({"kind": 3, "thirdVp": [0, 500]}));
let g = grid(&s);
assert!(near(g.vp_vertical[0], g.station().x) && near(g.horizon - g.vp_vertical[1], 500.0));
}
#[test]
fn grid_definition_fields_survive_a_native_round_trip() {
let mut s = session();
run(&mut s, "perspective.grid.define", json!({"units": "cm", "scale": [1, 4], "angle": 40, "groundColor": "#123456", "opacity": 20}));
let doc = s.doc().unwrap().doc.clone();
vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(PerspectiveGrid::from_doc(&back), PerspectiveGrid::from_doc(&doc));
let g = PerspectiveGrid::from_doc(&back).unwrap();
assert_eq!((g.units.as_str(), g.scale, g.ground_color, g.opacity), ("centimeters", [1.0, 4.0], Rgb([0x12, 0x34, 0x56]), 20.0));
// A grid saved before these fields loads with their defaults.
let mut old = serde_json::to_value(&g).unwrap();
for k in ["name", "angle", "units", "scale", "leftColor", "rightColor", "groundColor", "opacity"] {
old.as_object_mut().unwrap().remove(k);
}
let g: PerspectiveGrid = serde_json::from_value(old).unwrap();
assert_eq!((g.angle, g.units.as_str(), g.scale, g.opacity), (None, "points", [1.0, 1.0], 50.0));
}
// ---------- M8.9: presets ----------
fn preset_names(s: &mut Session) -> Vec<String> {
let r = run(s, "perspective.presets.list", json!({}));
r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn built_in_views_apply_by_name_or_type() {
let mut s = session();
let names = preset_names(&mut s);
assert_eq!(&names[..3], ["[1P-Normal View]", "[1P-Low View]", "[1P-High View]"]);
assert!(names.contains(&"[3P-Normal View]".to_string()));
let r = run(&mut s, "perspective.presets.list", json!({}));
assert!(r["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == json!(true)));
run(&mut s, "perspective.grid.preset", json!({"name": "[2P-low view]"}));
let low = grid(&s);
assert_eq!((low.name.as_str(), low.kind, low.visible), ("[2P-Low View]", 2, true));
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let normal = grid(&s);
assert_eq!(normal.name, "[2P-Normal View]");
assert!(low.origin[1] - low.horizon < normal.origin[1] - normal.horizon, "the low view's horizon is nearer the ground");
assert!(s.execute("perspective.grid.preset", &json!({"name": "[9P-Fisheye]"})).is_err());
assert!(s.execute("perspective.grid.preset", &json!({})).is_err());
}
#[test]
fn presets_save_rename_delete_and_protect_the_built_in_ones() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 3}));
run(&mut s, "perspective.grid.define", json!({"angle": 35, "opacity": 30}));
// Save Grid as Preset: the document's grid under a name, not an undo step.
let n = undo_len(&s);
let r = run(&mut s, "perspective.presets.save", json!({"name": "Tower"}));
assert_eq!((r["name"].clone(), r["created"].clone(), r["definition"]["kind"].clone()), (json!("Tower"), json!(true), json!(3)));
assert_eq!(undo_len(&s), n);
let saved = &s.prefs.perspective_presets[0];
assert!((saved.angle - 35.0).abs() < 1e-9 && saved.opacity == 30.0);
// A grid with its fields is that preset; changing one makes it custom again.
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
run(&mut s, "perspective.grid.preset", json!({"name": "tower"}));
assert_eq!(grid(&s).name, "Tower");
assert!((grid(&s).viewing_angle() - 35.0).abs() < 1e-9);
run(&mut s, "perspective.grid.define", json!({"opacity": 31}));
assert_eq!(grid(&s).name, "");
run(&mut s, "perspective.grid.define", json!({"name": "Tower", "opacity": 30}));
assert_eq!(grid(&s).name, "Tower");
// Edit, rename, start from another preset.
let r = run(&mut s, "perspective.presets.save", json!({"name": "Tower", "newName": "Spire", "gridline": 12}));
assert_eq!((r["name"].clone(), r["created"].clone()), (json!("Spire"), json!(false)));
assert_eq!(s.prefs.perspective_presets[0].gridline, 12.0);
assert_eq!(run(&mut s, "perspective.presets.save", json!({"preset": "[1P-Normal View]"}))["name"], json!("Perspective Preset 1"));
assert_eq!(s.prefs.perspective_presets[1].kind, 1);
// Built-in names are protected and names stay unique.
assert!(s.execute("perspective.presets.save", &json!({"name": "[2P-Normal View]"})).is_err());
assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "newName": "[1P-Low View]"})).is_err());
assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "newName": "perspective preset 1"})).is_err());
assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "angle": 95})).is_err());
assert!(s.execute("perspective.presets.delete", &json!({"name": "[3P-Low View]"})).is_err());
assert_eq!(run(&mut s, "perspective.presets.delete", json!({"name": "spire"}))["deleted"], json!("Spire"));
assert!(s.execute("perspective.presets.delete", &json!({"name": "Spire"})).is_err());
assert_eq!(preset_names(&mut s).last().unwrap(), "Perspective Preset 1");
}
#[test]
fn presets_export_import_and_live_in_the_preferences() {
let mut s = session();
run(&mut s, "perspective.presets.save", json!({"name": "Street", "units": "inches", "gridline": 1, "distance": 8, "horizonHeight": 4}));
let text = run(&mut s, "perspective.presets.export", json!({"names": ["Street", "[2P-High View]"]}))["data"].as_str().unwrap().to_string();
assert!(text.contains("vcperspective"));
let mut t = session();
let r = run(&mut t, "perspective.presets.import", json!({"data": text}));
assert_eq!(r["imported"], json!(["Street", "2P-High View"]), "a built-in view comes in as a saved copy");
assert_eq!(t.prefs.perspective_presets[0], s.prefs.perspective_presets[0]);
// Again: names in use get a number, or replace.
assert_eq!(run(&mut t, "perspective.presets.import", json!({"data": text}))["imported"], json!(["Street 2", "2P-High View 2"]));
assert_eq!(run(&mut t, "perspective.presets.import", json!({"data": text, "replace": true}))["imported"], json!(["Street", "2P-High View"]));
assert_eq!(t.prefs.perspective_presets.len(), 4);
// Bad files are refused whole.
for bad in [
json!({"data": "{}"}),
json!({"data": "{\"format\": \"vcprintpresets\", \"presets\": []}"}),
json!({"data": "{\"format\": \"vcperspective\", \"presets\": [{\"name\": \"ok\"}, {\"name\": \"x\", \"angle\": 120}]}"}),
json!({"data": "{\"format\": \"vcperspective\", \"presets\": [{\"kind\": \"two\"}]}"}),
json!({"dataBase64": "%%%"}),
json!({}),
] {
assert!(t.execute("perspective.presets.import", &bad).is_err(), "{bad}");
}
assert_eq!(t.prefs.perspective_presets.len(), 4);
assert!(Session::new().execute("perspective.presets.export", &json!({})).is_err(), "nothing saved to export");
// Saved with the preferences; older preferences have none.
let back: Prefs = serde_json::from_value(serde_json::to_value(&t.prefs).unwrap()).unwrap();
assert_eq!(back.perspective_presets, t.prefs.perspective_presets);
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().perspective_presets.is_empty());
}
// ---------- M8.10: view options ----------
use vectorcraft_geom::Point;
use vectorcraft_tools::distort::perspective::Plane;
use vectorcraft_tools::{Overlay, PointerEvent, PointerKind};
fn drag(s: &mut Session, tool: &str, from: Point, to: Point) {
let v = ViewInfo::default();
s.select_tool(tool, v).unwrap();
for (k, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::new(k, p.x, p.y), v).unwrap();
}
}
#[test]
fn view_options_toggle_without_undo_steps_and_survive_presets() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let n = undo_len(&s);
let g = grid(&s);
assert!(g.snap && !g.locked && !g.lock_station && !g.rulers, "Snap to Grid is on by default");
for (id, on) in
[("perspective.grid.lock", true), ("perspective.grid.lockStation", true), ("perspective.grid.snap", false), ("perspective.grid.rulers", true)]
{
assert_eq!(run(&mut s, id, json!({})), json!({"on": on}), "{id} toggles");
}
assert_eq!(run(&mut s, "perspective.grid.rulers", json!({"on": true})), json!({"on": true}));
assert_eq!(undo_len(&s), n);
run(&mut s, "perspective.grid.preset", json!({"name": "[2P-High View]"}));
let g = grid(&s);
assert!(g.locked && g.lock_station && !g.snap && g.rulers, "a preset keeps the view options");
// Saved with the document.
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(PerspectiveGrid::from_doc(&back), Some(g));
}
#[test]
fn lock_grid_keeps_the_widgets_still_but_planes_switch() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
run(&mut s, "perspective.grid.lock", json!({"on": true}));
let g = grid(&s);
let n = undo_len(&s);
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 50.0, g.horizon));
assert_eq!((grid(&s).vp_left, undo_len(&s)), (g.vp_left, n));
let (faces, _) = g.widget(&s.doc().unwrap().doc, 1.0);
let q = faces[2].1;
let c = Point::new((q[0].x + q[2].x) / 2.0, (q[0].y + q[2].y) / 2.0);
drag(&mut s, "perspectiveGrid", c, c);
assert_eq!(grid(&s).plane, Plane::Ground);
run(&mut s, "perspective.grid.lock", json!({"on": false}));
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 50.0, g.horizon));
assert!((grid(&s).vp_left - (g.vp_left - 50.0)).abs() < 1e-6);
}
#[test]
fn lock_station_point_swings_the_other_vanishing_point() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let st = g.station();
// Unlocked: only the dragged point moves (the viewing angle stays).
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 60.0, g.horizon));
assert_eq!(grid(&s).vp_right, g.vp_right);
s.execute("edit.undo", &json!({})).unwrap();
run(&mut s, "perspective.grid.lockStation", json!({"on": true}));
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 60.0, g.horizon));
let n = grid(&s);
assert!((n.vp_left - (g.vp_left - 60.0)).abs() < 1e-6);
assert!(n.vp_right < g.vp_right, "the right vanishing point comes in");
let st2 = n.station();
assert!((st2.x - st.x).abs() < 1e-6 && (st2.distance - st.distance).abs() < 1e-6, "the viewer stays put");
assert!(n.viewing_angle() < 45.0);
}
#[test]
fn snap_to_grid_lands_drawn_and_moved_art_on_gridlines() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let on_line = |v: f64| (v / g.cell - (v / g.cell).round()).abs() < 1e-6;
// A corner a little off the gridlines (within a quarter cell) is drawn on them.
let a = g.to_page(Plane::Left, Point::new(2.0 * g.cell + 3.0, g.cell - 2.0)).unwrap();
let b = g.to_page(Plane::Left, Point::new(4.0 * g.cell - 1.0, 3.0 * g.cell + 4.0)).unwrap();
let rect = json!({"x": a.x.min(b.x), "y": a.y.min(b.y), "width": (a.x - b.x).abs(), "height": (a.y - b.y).abs()});
let r = run(&mut s, "perspective.draw", json!({"command": "shape.rectangle", "params": rect, "plane": "left"}));
let id = NodeId(r["id"].as_u64().unwrap());
let corners = |s: &Session| -> Vec<Point> {
let n = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone();
n.anchors().map(|(_, _, an)| g.to_plane(Plane::Left, an.p).unwrap()).collect()
};
assert!(corners(&s).iter().all(|q| on_line(q.x) && on_line(q.y)), "{:?}", corners(&s));
// Moved a cell and a bit: it lands a whole cell over.
let from = g.to_page(Plane::Left, Point::new(3.0 * g.cell, 2.0 * g.cell)).unwrap();
let to = g.to_page(Plane::Left, Point::new(4.0 * g.cell + 2.0, 2.0 * g.cell)).unwrap();
run(&mut s, "perspective.move", json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y]}));
assert!(corners(&s).iter().all(|q| on_line(q.x) && on_line(q.y)), "{:?}", corners(&s));
// Without snapping it moves exactly.
let before = corners(&s);
run(&mut s, "perspective.move", json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "snap": false}));
for (p, q) in before.iter().zip(corners(&s)) {
assert!((q.x - p.x - (g.cell + 2.0)).abs() < 1e-6 && (q.y - p.y).abs() < 1e-6);
}
}
#[test]
fn grid_overlays_use_the_colours_opacity_and_rulers() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
run(&mut s, "perspective.grid.define", json!({"leftColor": "#102030", "opacity": 20}));
let ov = s.overlays(ViewInfo::default());
assert!(ov.iter().any(|o| matches!(o, Overlay::GridLine { color: [0x10, 0x20, 0x30, 51], .. })));
let labels = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Label { .. })).count();
assert_eq!(labels(&ov), 0);
run(&mut s, "perspective.grid.rulers", json!({"on": true}));
assert!(labels(&s.overlays(ViewInfo::default())) > 2);
}
#[test]
fn snapped_moves_keep_objects_on_a_plane_together() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let rect_at = |s: &mut Session, u: f64, v: f64| {
let (a, b) = (g.to_page(Plane::Left, Point::new(u, v)).unwrap(), g.to_page(Plane::Left, Point::new(u + g.cell, v + g.cell)).unwrap());
let p = json!({"x": a.x.min(b.x), "y": a.y.min(b.y), "width": (a.x - b.x).abs(), "height": (a.y - b.y).abs()});
NodeId(run(s, "perspective.draw", json!({"command": "shape.rectangle", "params": p, "plane": "left"}))["id"].as_u64().unwrap())
};
let (a, b) = (rect_at(&mut s, 2.0 * g.cell, g.cell), rect_at(&mut s, 4.0 * g.cell + 7.0, g.cell));
let left = |s: &Session, id: NodeId| {
let p = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone();
p.anchors().map(|(_, _, an)| g.to_plane(Plane::Left, an.p).unwrap().x).fold(f64::MAX, f64::min)
};
let gap = left(&s, b) - left(&s, a);
let from = g.to_page(Plane::Left, Point::new(3.0 * g.cell, 2.0 * g.cell)).unwrap();
let to = g.to_page(Plane::Left, Point::new(4.0 * g.cell + 3.0, 2.0 * g.cell)).unwrap();
run(&mut s, "perspective.move", json!({"ids": [a.0, b.0], "from": [from.x, from.y], "to": [to.x, to.y]}));
assert!(near(left(&s, b) - left(&s, a), gap), "they move by the same amount");
assert!(near(left(&s, a), 3.0 * g.cell), "the joint bounds land on a gridline");
}
// ---------- M8.11: grid widgets and the Plane Switching Widget ----------
use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace};
use vectorcraft_tools::{Mods, ScreenFrame, ToolKey};
/// A 1000 × 700 window at zoom 2 showing the document from (100, 50).
fn screen() -> ScreenFrame {
ScreenFrame {
origin: Point::new(100.0, 50.0),
right: vectorcraft_geom::Vec2::new(0.5, 0.0),
down: vectorcraft_geom::Vec2::new(0.0, 0.5),
size: (1000.0, 700.0),
}
}
fn windowed() -> ViewInfo {
ViewInfo { zoom: 2.0, screen: Some(screen()), ..Default::default() }
}
/// The centre of the widget's `plane` face in `view`.
fn face(s: &Session, view: ViewInfo, corner: WidgetCorner, plane: Plane) -> Point {
let d = &s.doc().unwrap().doc;
let (faces, (c, _)) = grid(s).widget_at(d, 1.0 / view.zoom, WidgetPlace { screen: view.screen.as_ref(), corner });
match faces.into_iter().find(|f| f.0 == plane) {
Some((_, q)) => Point::new((q[0].x + q[1].x + q[2].x + q[3].x) / 4.0, (q[0].y + q[1].y + q[2].y + q[3].y) / 4.0),
None => c,
}
}
fn click(s: &mut Session, p: Point, view: ViewInfo) {
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, p.x, p.y), view).unwrap();
}
}
#[test]
fn the_plane_widget_stays_on_screen_and_takes_clicks_with_any_tool() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let view = windowed();
// In the window's top-left corner, whatever part of the document shows.
let p = face(&s, view, WidgetCorner::TopLeft, Plane::Right);
assert!(p.x > 100.0 && p.x < 100.0 + 40.0 && p.y > 50.0 && p.y < 50.0 + 40.0, "{p:?}");
// The Selection tool's click on it picks the plane and selects nothing.
s.select_tool("selection", view).unwrap();
let n = undo_len(&s);
click(&mut s, p, view);
assert_eq!(grid(&s).plane, Plane::Right);
assert!(s.doc().unwrap().selection.is_empty());
assert_eq!(undo_len(&s), n);
// While the grid is hidden only the perspective tools show it (and take its clicks).
run(&mut s, "perspective.grid.show", json!({"visible": false}));
let q = face(&s, view, WidgetCorner::TopLeft, Plane::Ground);
click(&mut s, q, view);
assert_eq!(grid(&s).plane, Plane::Right);
s.select_tool("perspectiveSelection", view).unwrap();
click(&mut s, q, view);
assert_eq!(grid(&s).plane, Plane::Ground);
// Perspective Grid Options move it to another corner or hide it.
assert_eq!(run(&mut s, "perspective.widget.options", json!({"position": "bottomRight"})), json!({"show": true, "position": "bottomRight"}));
let r = face(&s, view, WidgetCorner::BottomRight, Plane::Left);
assert!(r.x > 100.0 + 450.0 && r.y > 50.0 + 300.0, "{r:?}");
click(&mut s, r, view);
assert_eq!(grid(&s).plane, Plane::Left);
run(&mut s, "perspective.widget.options", json!({"show": false}));
let hidden = face(&s, view, WidgetCorner::BottomRight, Plane::Right);
click(&mut s, hidden, view);
assert_eq!(grid(&s).plane, Plane::Left, "a hidden widget takes no clicks");
assert!(s.execute("perspective.widget.options", &json!({"position": "middle"})).is_err());
assert!(s.execute("perspective.widget.options", &json!({"show": "yes"})).is_err());
}
#[test]
fn digit_keys_pick_the_plane_while_the_grid_shows() {
let mut s = session();
let view = ViewInfo::default();
assert!(!s.tool_claims_key(ToolKey::Digit(2), view), "no grid shown: the digit is free");
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
for (n, plane) in [(1, Plane::Left), (2, Plane::Ground), (3, Plane::Right), (4, Plane::None)] {
assert!(s.tool_claims_key(ToolKey::Digit(n), view));
s.tool_key(ToolKey::Digit(n), Mods::default(), view).unwrap();
assert_eq!(grid(&s).plane, plane, "key {n}");
}
assert!(!s.tool_claims_key(ToolKey::Digit(5), view));
}
#[test]
fn ground_level_points_move_the_whole_grid() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let o = Point::new(g.origin[0], g.origin[1]);
// The left ground-level point sits 24 px from the origin along the left wall's ground line.
let towards = g.to_page(Plane::Left, Point::new(g.cell, 0.0)).unwrap();
let gl = o + (towards - o).normalize() * 24.0;
drag(&mut s, "perspectiveGrid", gl, gl + vectorcraft_geom::Vec2::new(30.0, -20.0));
let n = grid(&s);
let moved = [n.origin[0] - o.x, n.origin[1] - o.y, n.horizon - g.horizon, n.vp_left - g.vp_left, n.vp_right - g.vp_right];
for (got, want) in moved.into_iter().zip([30.0, -20.0, -20.0, 30.0, 30.0]) {
assert!(near(got, want), "{moved:?}");
}
assert_eq!((n.cell, n.viewing_angle()), (g.cell, g.viewing_angle()), "the same grid, elsewhere");
// Shift keeps it on one axis.
s.execute("edit.undo", &json!({})).unwrap();
gesture_mods(&mut s, gl, gl + vectorcraft_geom::Vec2::new(30.0, -8.0), Mods { shift: true, ..Default::default() });
let n = grid(&s);
assert!(near(n.origin[0], o.x + 30.0) && n.origin[1] == o.y && n.horizon == g.horizon);
}
/// A Perspective Grid tool drag with modifiers held.
fn gesture_mods(s: &mut Session, from: Point, to: Point, mods: Mods) {
let v = ViewInfo::default();
s.select_tool("perspectiveGrid", v).unwrap();
for (k, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::new(k, p.x, p.y).with_mods(mods), v).unwrap();
}
}
#[test]
fn extents_cell_size_and_vanishing_points_follow_their_widgets() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
// The right extent alone.
let er = g.to_page(Plane::Right, Point::new(g.extent, 0.0)).unwrap();
let to = g.to_page(Plane::Right, Point::new(g.extent * 0.5, 0.0)).unwrap();
drag(&mut s, "perspectiveGrid", er, to);
let n = grid(&s);
assert!((n.right_extent() - g.extent * 0.5).abs() < 1e-6 && n.extent == g.extent, "{:?} {}", n.extent_right, n.extent);
assert_eq!(n.domain(Plane::Right).width(), n.right_extent());
// Alt drags both; Shift goes by whole cells.
let el = g.to_page(Plane::Left, Point::new(g.extent, 0.0)).unwrap();
let to = g.to_page(Plane::Left, Point::new(5.3 * g.cell, 0.0)).unwrap();
gesture_mods(&mut s, el, to, Mods { alt: true, shift: true, ..Default::default() });
let n = grid(&s);
assert!((n.extent - 5.0 * g.cell).abs() < 1e-6 && n.extent_right == Some(n.extent));
// The cell widget: a cell up the line where the planes meet (more cells when they're small).
let c = g.to_page(Plane::Left, Point::new(0.0, g.cell)).unwrap();
drag(&mut s, "perspectiveGrid", c, g.to_page(Plane::Left, Point::new(0.0, 40.0)).unwrap());
assert!((grid(&s).cell - 40.0).abs() < 1e-6, "{}", grid(&s).cell);
// Shift-dragging a vanishing point keeps the horizon.
gesture_mods(
&mut s,
Point::new(g.vp_right, g.horizon),
Point::new(g.vp_right + 20.0, g.horizon + 30.0),
Mods { shift: true, ..Default::default() },
);
assert_eq!((grid(&s).vp_right, grid(&s).horizon), (g.vp_right + 20.0, g.horizon));
}
#[test]
fn widget_options_and_the_right_extent_are_saved() {
let mut s = session();
run(&mut s, "perspective.widget.options", json!({"position": "Top Right"}));
let back: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
assert_eq!(back.perspective_widget.position, WidgetCorner::TopRight);
run(&mut s, "perspective.grid.set", json!({"extentRight": 123}));
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(PerspectiveGrid::from_doc(&back).unwrap().extent_right, Some(123.0));
assert!(s.execute("perspective.grid.set", &json!({"extentRight": -1})).is_err());
}
#[test]
fn perspective_grid_options_are_a_preference_group() {
let mut s = session();
run(&mut s, "prefs.set", json!({"key": "perspectiveWidget", "value": {"position": "bottomLeft"}}));
assert_eq!(s.prefs.perspective_widget.position, WidgetCorner::BottomLeft);
assert!(s.prefs.perspective_widget.show, "a partial object keeps the rest");
assert!(s.execute("prefs.set", &json!({"key": "perspectiveWidget", "value": {"position": "nowhere"}})).is_err());
run(&mut s, "prefs.reset", json!({}));
assert_eq!(s.prefs.perspective_widget, Default::default());
}
+263
View File
@@ -0,0 +1,263 @@
//! Puppet Warp: turning the art around a pin (`object.puppetWarp {angles}`) and the tool's pins.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
#[test]
fn a_pin_with_an_angle_turns_the_art_around_it() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
// One pin at the centre, held at a quarter turn: the 200 × 100 rectangle stands up.
s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[200, 150]], "angles": [90]})).unwrap();
let b = bounds(&s, a);
assert!((b.width() - 100.0).abs() < 4.0 && (b.height() - 200.0).abs() < 4.0, "{b:?}");
assert!(b.center().distance(Point::new(200.0, 150.0)) < 2.0, "{b:?}");
// null leaves a pin free; the list must be as long as the pins.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[210, 150]], "angles": [null]})).unwrap();
assert!((bounds(&s, a).x0 - 110.0).abs() < 0.5, "a free single pin only moves the art");
for bad in [json!([90, 0]), json!("90"), json!({})] {
assert!(s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[200, 150]], "angles": bad})).is_err(), "{bad}");
}
}
#[test]
fn alt_drag_with_the_tool_turns_the_art_in_one_undo_step() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("puppetWarp", v).unwrap();
s.set_tool_option("selectAllPins", &json!(false));
// Select the pin in the middle, then Alt-drag about a quarter turn around it, 20 pt out.
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0), v).unwrap();
let n = s.doc().unwrap().history.undo.len();
let alt = Mods { alt: true, ..Default::default() };
for (k, x, y) in
[(PointerKind::Down, 220.0, 150.0), (PointerKind::Drag, 214.0, 164.0), (PointerKind::Drag, 200.0, 170.0), (PointerKind::Up, 200.0, 170.0)]
{
s.pointer(&PointerEvent::new(k, x, y).with_mods(alt), v).unwrap();
}
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
let (cmd, p) = s.journal.last().unwrap();
assert_eq!(cmd, "object.puppetWarp");
// The turn is the angle the pointer swept around the pin.
let i = p["angles"].as_array().unwrap().iter().position(|a| a.is_number()).unwrap();
let (cx, cy) = (p["moved"][i][0].as_f64().unwrap(), p["moved"][i][1].as_f64().unwrap());
let swept = ((170.0 - cy).atan2(200.0 - cx) - (150.0 - cy).atan2(220.0 - cx)).to_degrees();
assert!((p["angles"][i].as_f64().unwrap() - swept).abs() < 1e-6 && swept > 60.0, "{p}");
let b = bounds(&s, a);
assert!(b.height() > 110.0, "the art turned: {b:?}");
}
// ---------- the pins' session (M8.6) ----------
fn pins(s: &mut Session) -> Value {
s.execute("object.puppetWarp.pins", &json!({})).unwrap()
}
fn anchors(s: &Session, id: NodeId) -> Vec<Point> {
let mut out = vec![];
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| {
if let Some(p) = n.path_data() {
out.extend(p.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)));
}
});
out
}
fn worst(a: &[Point], b: &[Point]) -> f64 {
assert_eq!(a.len(), b.len());
a.iter().zip(b).map(|(x, y)| x.distance(*y)).fold(0.0, f64::max)
}
fn click(s: &mut Session, x: f64, y: f64) {
let v = ViewInfo::default();
s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap();
}
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
let v = ViewInfo::default();
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap();
}
fn pin_overlays(s: &mut Session) -> usize {
s.overlays(ViewInfo::default()).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Anchor { .. })).count()
}
#[test]
fn pins_appear_when_the_tool_is_chosen_or_art_selected() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0);
// Art selected, then the tool: the automatic pins show at once (the centre and both ends).
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
assert_eq!(pin_overlays(&mut s), 3);
let p = pins(&mut s);
assert_eq!((p["auto"].clone(), p["ids"].clone(), p["rest"].clone()), (json!(true), json!([a.0]), json!(true)));
assert_eq!(p["pins"], p["moved"], "automatic pins sit where they started");
// The tool first, then art selected.
s.execute("select.none", &json!({})).unwrap();
assert_eq!(pin_overlays(&mut s), 0);
assert!(s.execute("object.puppetWarp.pins", &json!({})).is_err(), "nothing selected");
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
assert_eq!(pin_overlays(&mut s), 3);
}
#[test]
fn warps_start_from_the_rest_shape_and_dont_stack() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0);
let rest = anchors(&s, a);
let p = pins(&mut s);
// Move the right-end pin up, then back: the original comes back.
let right = p["pins"].as_array().unwrap().iter().position(|q| q[0].as_f64().unwrap() > 300.0).unwrap();
let mut up = p.clone();
up["moved"][right][1] = json!(up["moved"][right][1].as_f64().unwrap() - 60.0);
s.execute("object.puppetWarp", &up).unwrap();
let once = anchors(&s, a);
assert!(worst(&rest, &once) > 30.0);
// The same pins again: the same warp (it doesn't stack on the last one).
s.execute("object.puppetWarp", &up).unwrap();
assert!(worst(&once, &anchors(&s, a)) < 1e-9);
s.execute("object.puppetWarp", &p).unwrap();
assert!(worst(&rest, &anchors(&s, a)) < 0.01, "back to the original");
// The query reads the pins back: where they started, where they are.
let now = pins(&mut s);
assert_eq!((now["pins"].clone(), now["auto"].clone()), (p["pins"].clone(), json!(false)));
// A pin off the mesh is refused.
let mut off = p.clone();
off["pins"][0] = json!([900, 900]);
let e = s.execute("object.puppetWarp", &off).unwrap_err().to_string();
assert!(e.contains("off the artwork"), "{e}");
}
#[test]
fn pins_follow_undo_and_redo() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0);
let rest = anchors(&s, a);
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
// Adding a pin is one undo step; dragging it is another.
click(&mut s, 250.0, 150.0);
let added = pins(&mut s);
assert_eq!(added["pins"].as_array().unwrap().len(), 4);
drag(&mut s, (250.0, 150.0), (250.0, 110.0));
let warped = anchors(&s, a);
let moved = pins(&mut s);
assert_eq!(moved["moved"][3], json!([250.0, 110.0]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pins(&mut s), added, "the pin went back with the art");
assert!(worst(&rest, &anchors(&s, a)) < 1e-9);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pins(&mut s)["auto"], json!(true), "no pin placed");
assert_eq!(pin_overlays(&mut s), 3);
s.execute("edit.redo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(pins(&mut s), moved);
assert!(worst(&warped, &anchors(&s, a)) < 1e-9);
// Pins persist across tool switches while the selection stays.
s.select_tool("selection", ViewInfo::default()).unwrap();
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
assert_eq!(pins(&mut s), moved);
assert_eq!(pin_overlays(&mut s), 4);
}
#[test]
fn a_new_selection_clears_the_pins() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0);
let b = rect(&mut s, 100.0, 300.0, 50.0, 50.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
click(&mut s, 250.0, 150.0);
drag(&mut s, (250.0, 150.0), (250.0, 120.0));
let warped = anchors(&s, a);
assert_eq!(pins(&mut s)["auto"], json!(false));
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let dirty = s.doc().unwrap().is_dirty();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
// Fresh automatic pins on the art as it is now (the warp stays).
let p = pins(&mut s);
assert_eq!(p["auto"], json!(true));
assert!(worst(&warped, &anchors(&s, a)) < 1e-9);
assert_eq!((s.doc().unwrap().history.undo.len(), s.doc().unwrap().is_dirty()), (undo, dirty), "not an edit");
// Something else editing the art keeps the pins, starting again from the art as it is.
click(&mut s, 200.0, 140.0);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
let p2 = pins(&mut s);
assert_eq!(p2["auto"], json!(false));
assert_eq!(p2["pins"], p2["moved"]);
}
#[test]
fn puppet_warp_works_on_groups_compound_paths_and_open_strokes() {
for kind in ["group", "compound", "open"] {
let mut s = session();
let id = match kind {
"open" => {
let r = s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 400, "y2": 200})).unwrap();
s.execute("stroke.set", &json!({"weight": 8})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
_ => {
let a = rect(&mut s, 100.0, 170.0, 140.0, 60.0);
let b = rect(&mut s, 260.0, 170.0, 140.0, 60.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let cmd = if kind == "group" { "object.group" } else { "object.compoundPath.make" };
let r = s.execute(cmd, &json!({})).unwrap();
r["id"].as_u64().map(NodeId).unwrap_or_else(|| s.doc().unwrap().selection.objects[0])
}
};
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let rest = anchors(&s, id);
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
// Pin both ends, lift the right one with the tool, then put it back.
click(&mut s, 104.0, 200.0);
click(&mut s, 396.0, 200.0);
drag(&mut s, (396.0, 200.0), (396.0, 140.0));
assert!(worst(&rest, &anchors(&s, id)) > 20.0, "{kind}: warped");
let n = s.doc().unwrap().doc.node(id).unwrap().children().map(Vec::len);
drag(&mut s, (396.0, 140.0), (396.0, 200.0));
assert!(worst(&rest, &anchors(&s, id)) < 0.05, "{kind}: back to the original");
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().children().map(Vec::len), n, "{kind}: the structure stays");
}
}
#[test]
fn pins_are_never_saved() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 300.0, 60.0);
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
click(&mut s, 250.0, 150.0);
assert!(s.doc().unwrap().doc.puppet.is_some());
let r = s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap();
let data = r["dataBase64"].as_str().unwrap().to_string();
s.execute("document.open", &json!({"name": "pins.vectorcraft", "dataBase64": data})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.puppet.is_none());
assert_eq!(st.doc.node_count(), 2, "layer + rectangle");
}
+98
View File
@@ -0,0 +1,98 @@
//! Tool options that persist across tool switches, documents and launches (`Prefs::tool_settings`).
use serde_json::json;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn select(s: &mut Session, tool: &str) {
s.select_tool(tool, ViewInfo::default()).unwrap();
}
#[test]
fn options_survive_a_tool_switch() {
let mut s = session();
select(&mut s, "mirrorCut");
s.set_tool_option("axis", &json!("horizontal"));
s.set_tool_option("keep", &json!("bottom"));
select(&mut s, "selection");
select(&mut s, "mirrorCut");
assert_eq!(s.tool_options(), json!({"axis": "horizontal", "keep": "bottom"}));
// Interaction state isn't kept: the Rotate tool's reference point starts afresh.
select(&mut s, "rotate");
s.set_tool_option("origin", &json!([10, 20]));
select(&mut s, "selection");
select(&mut s, "rotate");
assert_eq!(s.tool_options(), json!({"origin": null}));
}
#[test]
fn liquify_tools_share_the_global_brush_but_not_their_own_options() {
let mut s = session();
select(&mut s, "warp");
s.set_tool_option("width", &json!(60));
s.set_tool_option("intensity", &json!(80));
s.set_tool_option("detail", &json!(7));
select(&mut s, "twirl");
let o = s.tool_options();
assert_eq!((o["width"].as_f64(), o["intensity"].as_f64(), o["detail"].as_f64()), (Some(60.0), Some(0.8), Some(2.0)));
s.set_tool_option("rate", &json!(-90));
select(&mut s, "warp");
assert_eq!(s.tool_options()["detail"], json!(7.0));
select(&mut s, "twirl");
assert_eq!(s.tool_options()["rate"], json!(-90.0));
}
#[test]
fn another_tools_options_are_stored_and_shared_ones_reach_the_active_tool() {
let mut s = session();
select(&mut s, "warp");
let o = s.set_tool_option_cmd(&json!({"tool": "pucker", "values": {"width": 40, "height": 30, "detail": 5}})).unwrap();
assert_eq!((o["tool"].as_str(), o["detail"].as_f64()), (Some("pucker"), Some(5.0)));
assert_eq!((s.tool_id(), s.tool_options()["width"].as_f64(), s.tool_options()["detail"].as_f64()), ("warp", Some(40.0), Some(2.0)));
assert_eq!(s.tool_options_of("pucker")["height"], json!(30.0));
select(&mut s, "pucker");
assert_eq!(s.tool_options()["detail"], json!(5.0));
// `{}` reads the options; an unknown tool is an error.
assert_eq!(s.set_tool_option_cmd(&json!({})).unwrap(), s.tool_options());
assert!(s.set_tool_option_cmd(&json!({"tool": "nope", "key": "width", "value": 1})).is_err());
}
#[test]
fn a_gesture_keeps_what_it_changed_and_a_document_switch_keeps_options() {
let mut s = session();
select(&mut s, "spiral");
s.set_tool_option("segments", &json!(14));
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
assert_eq!(s.tool_id(), "spiral");
assert_eq!(s.tool_options()["segments"], json!(14));
s.set_active(0);
assert_eq!(s.tool_options()["segments"], json!(14));
}
#[test]
fn options_are_saved_with_the_preferences() {
let mut s = session();
select(&mut s, "polygon");
s.set_tool_option("sides", &json!(9));
select(&mut s, "selection");
let saved = serde_json::to_string(&s.prefs).unwrap();
assert!(saved.contains("toolSettings"), "{saved}");
// Next launch.
let mut t = session();
t.apply_prefs(serde_json::from_str(&saved).unwrap());
select(&mut t, "polygon");
assert_eq!(t.tool_options()["sides"], json!(9));
// Older preference files have none; none are written until a tool keeps something.
assert!(Prefs::default().to_json().get("toolSettings").is_none());
let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap();
assert!(old.tool_settings.is_empty());
// Resetting the preferences keeps them.
t.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9));
}
+52
View File
@@ -0,0 +1,52 @@
//! The Width tool on compound paths: the stroke is the compound's, so its members are edited
//! through it, by the tool and by the width point commands given a member.
use serde_json::json;
use vectorcraft_tools::{PointerEvent, PointerKind};
use super::*;
/// Two 200 pt lines made into a compound path with a 10 pt stroke → (compound, members).
fn compound() -> (Session, NodeId, Vec<NodeId>) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
let members: Vec<NodeId> = [200, 300]
.iter()
.map(|y| NodeId(s.execute("shape.line", &json!({"x1": 100, "y1": y, "x2": 300, "y2": y})).unwrap()["id"].as_u64().unwrap()))
.collect();
s.execute("select.set", &json!({"ids": [members[0].0, members[1].0]})).unwrap();
let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
s.execute("stroke.set", &json!({"weight": 10})).unwrap();
(s, c, members)
}
fn profile(s: &Session, id: NodeId) -> Option<Vec<(f64, f64, f64)>> {
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().and_then(|st| st.profile.as_ref().map(|p| p.points.clone()))
}
#[test]
fn the_width_tool_edits_a_compound_paths_stroke() {
let (mut s, c, members) = compound();
assert!(s.doc().unwrap().doc.node(members[1]).is_some(), "members keep their ids");
let v = ViewInfo::default();
s.select_tool("width", v).unwrap();
// Drag out from the second member's middle: 20 pt either side.
for (k, y) in [(PointerKind::Down, 301.0), (PointerKind::Drag, 310.0), (PointerKind::Drag, 320.0), (PointerKind::Up, 320.0)] {
s.pointer(&PointerEvent::new(k, 200.0, y), v).unwrap();
}
let p = profile(&s, c).expect("the compound's stroke has the width point");
assert!(p.iter().any(|q| (q.0 - 0.5).abs() < 1e-3 && (q.1 - 4.0).abs() < 1e-6), "{p:?}");
assert_eq!(s.journal.last().unwrap().1["id"], json!(c.0));
}
#[test]
fn width_point_commands_given_a_member_edit_the_compound() {
let (mut s, c, members) = compound();
s.execute("stroke.widthPoint.set", &json!({"id": members[0].0, "t": 0.25, "left": 10, "right": 10})).unwrap();
assert_eq!(profile(&s, c).unwrap().len(), 3);
s.execute("stroke.widthPoint.remove", &json!({"id": members[1].0, "index": 1})).unwrap();
assert_eq!(profile(&s, c).unwrap().len(), 2);
s.execute("stroke.widthProfile.set", &json!({"ids": [members[0].0, members[1].0], "points": [[0, 0, 0], [1, 2, 2]]})).unwrap();
assert_eq!(profile(&s, c).unwrap(), vec![(0.0, 0.0, 0.0), (1.0, 2.0, 2.0)]);
}
+145 -12
View File
@@ -1,10 +1,11 @@
//! Hosting the active tool: pointer/key events → actions → commands.
use serde_json::Value;
use serde_json::{Map, Value};
use vectorcraft_geom::Point;
use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey};
use vectorcraft_tools::distort::perspective::widget::WidgetPlace;
use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings};
use crate::{EngineError, Result, Session};
use crate::{EngineError, Prefs, Result, Session};
/// View state the tools need from the frontend.
#[derive(Clone, Copy, Debug)]
@@ -20,6 +21,8 @@ pub struct ViewInfo {
pub snap_to_point: bool,
/// View → Show Corner Widget.
pub corner_widgets: bool,
/// The document window (none headless): screen-fixed widgets sit in it.
pub screen: Option<vectorcraft_tools::ScreenFrame>,
}
impl Default for ViewInfo {
@@ -33,6 +36,7 @@ impl Default for ViewInfo {
snap_to_pixel: false,
snap_to_point: true,
corner_widgets: true,
screen: None,
}
}
}
@@ -42,6 +46,9 @@ impl Default for ViewInfo {
pub enum UiRequest {
Dialog(String, Value),
SwitchTool(String),
/// A message for the status bar: the `warning` of a command the tool ran (Liquify skipping
/// type under the brush).
Status(String),
}
impl Session {
@@ -56,10 +63,50 @@ impl Session {
}
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
self.apply_actions(acts)?;
self.tool = vectorcraft_tools::create(id);
self.keep_tool_settings();
self.tool = self.make_tool(id);
Ok(())
}
/// A fresh `id` tool with the options it keeps ([`settings`]): its last values, from this
/// session or a saved one.
pub(crate) fn make_tool(&self, id: &str) -> Box<dyn Tool> {
let mut t = vectorcraft_tools::create(id);
Self::restore_tool_settings(&self.prefs, t.as_mut());
t
}
/// Give `t` the persistent options stored for it in `prefs`.
fn restore_tool_settings(prefs: &Prefs, t: &mut dyn Tool) {
for (store, keys) in settings::stores(t.id()) {
let Some(saved) = prefs.tool_settings.get(store) else { continue };
for k in keys {
if let Some(v) = saved.get(*k) {
t.set_option(k, v);
}
}
}
}
/// Store the persistent options of `t` in `prefs` (so they are saved with them too).
fn store_tool_settings(prefs: &mut Prefs, t: &dyn Tool) {
let opts = t.options();
for (store, keys) in settings::stores(t.id()) {
let saved = prefs.tool_settings.entry(store.to_string()).or_default();
for k in keys {
if let Some(v) = opts.get(*k) {
saved.insert(k.to_string(), v.clone());
}
}
}
}
/// Keep the active tool's persistent options for the next time it, or a tool sharing them, is
/// made.
pub(crate) fn keep_tool_settings(&mut self) {
Self::store_tool_settings(&mut self.prefs, self.tool.as_ref());
}
pub(crate) fn with_tool_cx<R>(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn vectorcraft_tools::Tool, &ToolContext) -> R) -> R
where
R: Default,
@@ -91,6 +138,8 @@ impl Session {
paste_plain_text: self.prefs.paste_text_formatting == "plain",
slices_hidden: self.menu.slices_hidden,
slices_locked: self.menu.slices_locked,
screen: view.screen,
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
};
let tool = &mut self.tool;
match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) {
@@ -98,7 +147,7 @@ impl Session {
Err(msg) => {
// A bug in the tool: start it afresh and drop its half-done drag.
let id = self.tool.id();
self.tool = vectorcraft_tools::create(id);
self.tool = self.make_tool(id);
let _ = self.cancel_interaction();
self.tool_panic = Some(EngineError::Internal { cmd: format!("tool `{id}`"), msg });
R::default()
@@ -114,12 +163,41 @@ impl Session {
/// Feed a pointer event to the active tool. Returns requests for the UI.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
self.last_view = view;
// The Plane Switching Widget takes its clicks whatever the tool.
if let Some(r) = self.plane_widget_pointer(ev, view) {
return r;
}
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
self.take_tool_panic()?;
// A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
if ev.kind == PointerKind::Up {
self.keep_tool_settings();
}
self.apply_actions(acts)
}
/// Time passed while the pointer button is held (`dt` seconds; see [`Tool::tick`]): the
/// desktop app ticks every frame of a press, agents with `holdMs` on a pointer event.
pub fn tool_tick(&mut self, dt: f64, view: ViewInfo) -> Result<Vec<UiRequest>> {
if !self.tool.wants_ticks() {
return Ok(vec![]);
}
let acts = self.with_tool_cx(view, |t, cx| t.tick(cx, dt));
self.take_tool_panic()?;
self.apply_actions(acts)
}
/// Does the active tool want [`Session::tool_tick`]s now?
pub fn tool_wants_ticks(&self) -> bool {
self.tool.wants_ticks()
}
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
// 1–4 pick the active perspective plane while the grid is shown.
if let Some(plane) = self.plane_key(key) {
self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?;
return Ok(vec![]);
}
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
self.take_tool_panic()?;
self.apply_actions(acts)
@@ -142,13 +220,15 @@ impl Session {
/// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)?
pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool {
self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
if let Some(d) = self.active() {
v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays(&d.doc, 1.0 / view.zoom.max(1e-9), self.tool.id()));
let w = self.prefs.perspective_widget;
let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position });
v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), self.tool.id(), place));
}
v
}
@@ -162,6 +242,47 @@ impl Session {
}
pub fn set_tool_option(&mut self, key: &str, v: &Value) {
self.tool.set_option(key, v);
self.keep_tool_settings();
}
/// The options of tool `id`: the active tool's, or those it would have if chosen now.
pub fn tool_options_of(&self, id: &str) -> Value {
if id == self.tool.id() { self.tool.options() } else { self.make_tool(id).options() }
}
/// Set options of tool `id` (default: the active tool). Another tool's persistent options are
/// stored for when it is chosen, and the active tool takes those it shares with it (the
/// Liquify tools' Global Brush Dimensions). Returns the tool's options.
pub fn set_tool_options(&mut self, id: Option<&str>, values: &Map<String, Value>) -> Value {
let active = id.is_none_or(|id| id == self.tool.id());
let mut other = (!active).then(|| self.make_tool(id.unwrap_or_default()));
let t = other.as_deref_mut().unwrap_or(self.tool.as_mut());
for (k, v) in values {
t.set_option(k, v);
}
Self::store_tool_settings(&mut self.prefs, t);
let out = t.options();
if other.is_some() {
Self::restore_tool_settings(&self.prefs, self.tool.as_mut());
}
out
}
/// `tool.setOption {tool?, key?, value?, values?}` (a command of the desktop app and of the
/// headless host): set one option (`key`, `value`) or several (`values`) of `tool` (default:
/// the active tool) → its options.
pub fn set_tool_option_cmd(&mut self, p: &Value) -> std::result::Result<Value, String> {
let tool = p.get("tool").and_then(Value::as_str);
if let Some(t) = tool
&& vectorcraft_tools::tool_info(t).is_none()
{
return Err(format!("unknown tool `{t}`"));
}
let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
if let Some(k) = p.get("key").and_then(Value::as_str) {
values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
}
Ok(self.set_tool_options(tool, &values))
}
/// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]):
@@ -190,13 +311,25 @@ impl Session {
for a in acts {
match a {
Action::Begin(label) => self.begin_interaction(&label)?,
Action::Preview(cmd, p) => {
if let Err(e) = self.preview(&cmd, &p) {
log::warn!("preview {cmd}: {e}");
Action::Preview(cmd, p) => match self.preview(&cmd, &p) {
Ok(v) => {
if let Some(w) = v.get("warning").and_then(Value::as_str)
&& !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w))
{
ui.push(UiRequest::Status(w.to_string()));
}
}
Err(e) => log::warn!("preview {cmd}: {e}"),
},
// The drag is over: the dabs of a Liquify stroke have no further use.
Action::Commit => {
self.liquify_stroke = None;
self.commit_interaction()?
}
Action::Cancel => {
self.liquify_stroke = None;
self.cancel_interaction()?
}
Action::Commit => self.commit_interaction()?,
Action::Cancel => self.cancel_interaction()?,
Action::Exec(cmd, p) => {
self.execute(&cmd, &p)?;
}
+77
View File
@@ -224,6 +224,24 @@ fn structured_junk() {
("stroke.set", json!({"dash": [1e-12]})),
("transparency.set", json!({"opacity": -50})),
("transparency.set", json!({"opacity": 1e308, "blend": "NoSuchBlend"})),
("perspective.move", json!({"from": [0, 0], "to": [1e308, -1e308], "perpendicular": true, "copy": true, "plane": "ground"})),
("perspective.move", json!({"from": [f64::MAX, 0], "to": [0, 0], "plane": "left"})),
("perspective.transform", json!({"matrix": [1e308, 0, 0, 1e308, 0, 0], "plane": "right"})),
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": -1e9, "plane": "left", "copy": true})),
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 1e300, 0], "plane": "left"})),
("perspective.nudge", json!({"dx": 1e308, "dy": -1e308, "big": true, "copy": true})),
("perspective.plane.move", json!({"plane": "ground", "offset": 1e308, "objects": "copy"})),
("perspective.plane.move", json!({"plane": "left", "by": -1e7, "objects": "move"})),
("perspective.plane.move", json!({"plane": "none", "offset": 0})),
("perspective.plane.matchObject", json!({"id": u64::MAX})),
("perspective.grid.set", json!({"leftOffset": -1e12, "reproject": true})),
("perspective.grid.set", json!({"vpRight": -1e6, "reproject": true})),
("perspective.editText", json!({"id": u64::MAX})),
(
"perspective.draw",
json!({"command": "shape.rectangle", "params": {"x": 1, "y": 1, "width": 10, "height": 10}, "at": [1e308, -1e308], "plane": "left"}),
),
("perspective.draw", json!({"command": "shape.flare", "params": {"cx": 0, "cy": 0}, "plane": "ground"})),
("object.setProps", json!({"opacity": -1, "blend": 5})),
("effect.apply", json!({"effect": "distort.roughen", "params": {"size": 100, "detail": 100, "relative": false}})),
("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})),
@@ -262,6 +280,21 @@ fn structured_junk() {
("file.new", json!({"width": -1, "height": 0, "artboards": 1000000})),
("command.batch", json!({"commands": [{"command": 5}, {"params": 3}]})),
("command.batch", json!({"commands": "nope"})),
("object.puppetWarp", json!({"pins": [[0, 0]], "moved": [[0, 0]], "angles": [1e308]})),
("object.puppetWarp", json!({"pins": [[0, 0], [5, 5]], "moved": [[0, 0], [1e9, 1e9]], "angles": [-1e308, "x"]})),
("object.liquify", json!({"tool": "pucker", "points": [[0, 0, 1e308], [1, 1, -5], [2, 2, "x"]], "usePressure": true})),
("object.liquify", json!({"tool": "twirl", "points": vec![json!([50, 50, 0.5]); 400], "usePressure": "yes", "simplifyOn": 3})),
("object.liquify", json!({"tool": "wrinkle", "points": [[0, 0], [1e4, 1e4]], "width": 1e308, "complexity": -1, "affectIn": null})),
("object.blend.make", json!({"ids": [1, 2, 3], "starts": [u64::MAX, -1, "x", null, 1e308]})),
("object.blend.make", json!({"ids": [2, 2], "starts": {"a": 1}, "steps": 1e308})),
("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})),
("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})),
("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})),
("object.blend.spine.removeAnchor", json!({"anchor": -1})),
("object.puppetWarp", json!({"rest": true, "pins": [], "moved": []})),
("object.puppetWarp", json!({"rest": true, "pins": [[1e308, 0]], "moved": [[0, 0]], "expand": -5})),
("object.puppetWarp", json!({"rest": true, "ids": [u64::MAX], "pins": [[0, 0]], "moved": [[0, 0]]})),
("object.puppetWarp.pins", json!({"ids": [0, 1, 2], "expand": 1e308})),
];
let mut failures = vec![];
for fx in Fixture::ALL {
@@ -363,3 +396,47 @@ fn recovery_commands_with_a_damaged_store_and_junk_params() {
}
}
}
/// Envelope Distort's commands on warp, mesh and top-object envelopes (selected, and with their
/// content selected in Edit Contents) with `{}` and junk params: never a panic, an open
/// interaction or a broken tree.
#[test]
fn envelope_commands_with_junk_params_on_envelopes() {
let path = safe_path();
let makes: [(&str, Value); 3] = [
("object.envelope.makeWithWarp", json!({"style": "twist", "bend": 80})),
("object.envelope.makeWithMesh", json!({"rows": 2, "cols": 3})),
("object.envelope.makeWithTopObject", json!({})),
];
let mut failures = vec![];
for (make, mp) in &makes {
for editing in [false, true] {
let ids: Vec<&str> =
command_specs().iter().map(|c| c.id).filter(|id| id.starts_with("object.envelope.") || id.starts_with("object.mesh.")).collect();
for id in ids {
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
let mut s = Fixture::Multi.session();
if s.execute(make, mp).is_err() {
failures.push(format!("{make} failed on the Multi fixture"));
continue;
}
if editing {
let _ = s.execute("object.envelope.editContents", &json!({"editing": true}));
}
let r = catch_quiet(|| s.execute(id, &p));
if r.is_err() {
failures.push(format!("PANIC {id} {p} [{make}, editing {editing}]"));
} else if s.in_interaction() {
failures.push(format!("{id} {p} [{make}]: left an interaction open"));
} else if let Err(e) = check_session(&s) {
failures.push(format!("{id} {p} [{make}]: {e}"));
}
}
}
}
}
failures.sort();
failures.dedup();
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
}
+32 -1
View File
@@ -1097,7 +1097,7 @@ fn emf_of(records: &[(u32, Vec<u8>)]) -> Vec<u8> {
/// A placeable WMF of a 1 × 1 inch box holding `records`.
fn wmf_of(records: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut b: Vec<u8> = vec![0xD7, 0xCD, 0xC6, 0x9A, 0, 0, 0, 0, 0, 0, 0xA0, 0x05, 0xA0, 0x05, 0xA0, 0x05, 0, 0, 0, 0];
let sum = b.chunks_exact(2).fold(0u16, |a, w| a ^ u16::from_le_bytes([w[0], w[1]]));
let sum = b.as_chunks::<2>().0.iter().fold(0u16, |a, w| a ^ u16::from_le_bytes(*w));
b.extend(sum.to_le_bytes());
b.extend([1, 0, 9, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
for (function, body) in records {
@@ -1204,3 +1204,34 @@ proptest! {
print_presets("mutated print presets", &mutate_text(&text, cut, &edits))?;
}
}
// ---------- perspective grid presets files ----------
/// Import perspective grid presets from `data`, then apply each one and draw on its grid.
fn perspective_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "perspective.presets.import", data, |s, r| {
for name in r["imported"].as_array().into_iter().flatten() {
let _ = s.execute("perspective.grid.preset", &json!({"name": name}));
let _ = s.execute("perspective.grid.define", &json!({"name": name, "gridline": 3}));
let _ = s.execute("perspective.draw", &json!({"command": "shape.rectangle", "params": {"x": 300, "y": 400, "width": 40, "height": 30}}));
}
})
}
proptest! {
#![proptest_config(config())]
#[test]
fn perspective_presets_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "{\"format\": \"vcperspective\", ", "{\"format\": \"vcperspective\", \"presets\": [{\"name\": \"x\", \"kind\": 3, \"scale\": "])) {
perspective_presets("perspective presets garbage", &format!("{head}{s}"))?;
}
#[test]
fn mutated_perspective_presets_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) {
let mut s = rich_session();
s.execute("perspective.presets.save", &json!({"name": "Tall", "kind": 3, "units": "inches", "scale": [1, 4], "angle": 25, "thirdVp": [1, 30]})).unwrap();
s.execute("perspective.presets.save", &json!({"name": "Flat", "preset": "[1P-Low View]", "gridline": 4, "groundColor": "#00ff00"})).unwrap();
let text = s.execute("perspective.presets.export", &json!({"names": ["Tall", "Flat", "[2P-High View]"]})).unwrap()["data"].as_str().unwrap().to_string();
perspective_presets("mutated perspective presets", &mutate_text(&text, cut, &edits))?;
}
}
+2 -4
View File
@@ -429,10 +429,8 @@ impl Interp<'_> {
return Err(PsError::Limit("a path has too many points"));
}
let mut bp = BezPath::new();
for r in nums.chunks_exact(4) {
if let [x, y, w, h] = r {
bp.extend(self.rect_path(*x, *y, *w, *h));
}
for [x, y, w, h] in nums.as_chunks::<4>().0 {
bp.extend(self.rect_path(*x, *y, *w, *h));
}
Ok(bp)
}
+1 -1
View File
@@ -292,7 +292,7 @@ impl<'a> Scene<'a> {
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
}
+2
View File
@@ -10,6 +10,7 @@
pub mod bez;
pub mod hit;
pub mod path;
pub mod projective;
pub mod recognize;
pub mod shapes;
pub mod snap;
@@ -17,6 +18,7 @@ pub mod snap;
pub use kurbo;
pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2};
pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath};
pub use projective::Homography;
/// Tolerance used when comparing handle positions to anchor positions.
pub const EPS: f64 = 1e-9;
+19
View File
@@ -140,6 +140,25 @@ impl Anchor {
self.h_in += d;
self.h_out += d;
}
/// Place the end of the outgoing (`out`) or incoming handle at `pos`. `independent` makes the
/// anchor a corner first; a smooth anchor keeps its other handle in line, at its length.
pub fn set_handle(&mut self, out: bool, pos: Point, independent: bool) {
if independent {
self.kind = AnchorKind::Corner;
}
let p = self.p;
let smooth = self.kind == AnchorKind::Smooth;
let (moved, other) = if out { (&mut self.h_out, &mut self.h_in) } else { (&mut self.h_in, &mut self.h_out) };
*moved = pos;
if smooth {
let len = (*other - p).hypot();
let dir = p - pos;
let l = dir.hypot();
if l > 1e-9 {
*other = p + dir * (len / l);
}
}
}
/// Swap handles (used when reversing path direction).
pub fn reversed(&self) -> Self {
Self { p: self.p, h_in: self.h_out, h_out: self.h_in, kind: self.kind }
+186
View File
@@ -0,0 +1,186 @@
//! Projective (perspective) transforms of the plane: 3×3 homographies.
//!
//! The Perspective Grid maps each grid plane to the page with one, objects in perspective move by
//! conjugating plane-space affine maps with it, and type and symbol instances in perspective keep
//! one that projects their flat art.
use kurbo::{Affine, Point, Rect};
/// A 3×3 projective transform (row-major), acting on `(x, y, 1)`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Homography(pub [[f64; 3]; 3]);
impl Homography {
pub const IDENTITY: Self = Self([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
/// From the images of (1,0,0), (0,1,0) and (0,0,1).
pub fn from_cols(c1: [f64; 3], c2: [f64; 3], c3: [f64; 3]) -> Self {
Self([[c1[0], c2[0], c3[0]], [c1[1], c2[1], c3[1]], [c1[2], c2[2], c3[2]]])
}
/// The affine map `a` as a homography.
pub fn from_affine(a: Affine) -> Self {
let [m0, m1, m2, m3, m4, m5] = a.as_coeffs();
Self([[m0, m2, m4], [m1, m3, m5], [0.0, 0.0, 1.0]])
}
/// From nine row-major numbers (as stored in documents); `None` unless all are finite and the
/// map is invertible.
pub fn from_array(v: [f64; 9]) -> Option<Self> {
if v.iter().any(|x| !x.is_finite()) {
return None;
}
let h = Self([[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]]);
(h.det().abs() > 1e-15).then_some(h)
}
/// The nine row-major numbers.
pub fn to_array(&self) -> [f64; 9] {
let m = &self.0;
[m[0][0], m[0][1], m[0][2], m[1][0], m[1][1], m[1][2], m[2][0], m[2][1], m[2][2]]
}
/// Map a point; `None` when it lands on or beyond the horizon (w ≤ 0).
pub fn apply(&self, p: Point) -> Option<Point> {
let m = &self.0;
let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2];
let y = m[1][0] * p.x + m[1][1] * p.y + m[1][2];
let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2];
if w <= 1e-9 || !x.is_finite() || !y.is_finite() {
return None;
}
Some(Point::new(x / w, y / w))
}
pub fn det(&self) -> f64 {
let m = &self.0;
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
/// Inverse.
pub fn inverse(&self) -> Option<Self> {
let m = &self.0;
let d = self.det();
if d.abs() < 1e-15 || !d.is_finite() {
return None;
}
let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0];
let adj = [
[c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)],
[-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)],
[c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)],
];
let mut out = [[0.0; 3]; 3];
for (r, row) in adj.iter().enumerate() {
for (k, v) in row.iter().enumerate() {
out[r][k] = v / d;
}
}
// H⁻¹·(x, y, 1) = (u, v, 1)/w_H, so points in front of the camera keep w > 0.
Some(Self(out))
}
/// `self ∘ other`: `other` first.
pub fn then_after(&self, other: &Self) -> Self {
let (a, b) = (&self.0, &other.0);
let mut out = [[0.0; 3]; 3];
for (r, row) in out.iter_mut().enumerate() {
for (k, v) in row.iter_mut().enumerate() {
*v = a[r][0] * b[0][k] + a[r][1] * b[1][k] + a[r][2] * b[2][k];
}
}
Self(out)
}
/// `self` seen through `a`: the map `a ∘ self ∘ a⁻¹` (what `self` becomes when everything it
/// maps from and to is moved by `a`). `None` when `a` isn't invertible.
pub fn conjugated(&self, a: Affine) -> Option<Self> {
if a.determinant().abs() < 1e-15 {
return None;
}
Some(Self::from_affine(a).then_after(self).then_after(&Self::from_affine(a.inverse())))
}
/// The affine map that matches `self` to first order at `p` (its derivative there), `None`
/// beyond the horizon.
pub fn affine_at(&self, p: Point) -> Option<Affine> {
let m = &self.0;
let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2];
let q = self.apply(p)?;
// d(x/w) = (dx − (x/w)·dw) / w, and the same for y.
let j = |r: usize, c: usize| (m[r][c] - [q.x, q.y][r] * m[2][c]) / w;
let lin = Affine::new([j(0, 0), j(1, 0), j(0, 1), j(1, 1), 0.0, 0.0]);
Some(Affine::translate(q.to_vec2()) * lin * Affine::translate(-p.to_vec2()))
}
/// The bounding box of the image of `r` (lines stay lines: its corners bound it), `None` when
/// part of it crosses the horizon.
pub fn map_rect_bbox(&self, r: Rect) -> Option<Rect> {
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
let mut out: Option<Rect> = None;
for p in pts {
let q = self.apply(p)?;
out = Some(out.map_or(Rect::from_points(q, q), |b| b.union_pt(q)));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn h() -> Homography {
Homography([[1.2, 0.1, 5.0], [-0.2, 0.9, 3.0], [0.001, 0.002, 1.0]])
}
#[test]
fn compose_invert_and_round_trip() {
let a = h();
let inv = a.inverse().unwrap();
let id = a.then_after(&inv);
for (r, row) in id.0.iter().enumerate() {
for (k, v) in row.iter().enumerate() {
assert!((v - if r == k { 1.0 } else { 0.0 }).abs() < 1e-9);
}
}
let p = Point::new(30.0, -12.0);
assert!(inv.apply(a.apply(p).unwrap()).unwrap().distance(p) < 1e-9);
assert_eq!(Homography::from_array(a.to_array()), Some(a));
assert!(Homography::from_array([f64::NAN; 9]).is_none());
assert!(Homography::from_array([0.0; 9]).is_none());
}
#[test]
fn affine_and_conjugation() {
let t = Affine::translate((10.0, -4.0)) * Affine::scale(2.0);
let p = Point::new(3.0, 7.0);
assert!(Homography::from_affine(t).apply(p).unwrap().distance(t * p) < 1e-12);
// Conjugating: moving the input and the output by `t` keeps the picture moved by `t`.
let c = h().conjugated(t).unwrap();
assert!(c.apply(t * p).unwrap().distance(t * h().apply(p).unwrap()) < 1e-9);
assert!(h().conjugated(Affine::scale(0.0)).is_none());
}
#[test]
fn affine_at_is_the_derivative() {
let p = Point::new(12.0, -7.0);
let a = h().affine_at(p).unwrap();
assert!(a * p == h().apply(p).unwrap() || (a * p).distance(h().apply(p).unwrap()) < 1e-9);
let e = 1e-4;
for d in [kurbo::Vec2::new(e, 0.0), kurbo::Vec2::new(0.0, e)] {
let exact = h().apply(p + d).unwrap();
assert!((a * (p + d)).distance(exact) < 1e-7, "{d:?}");
}
}
#[test]
fn rect_bbox_is_the_corners_box() {
let b = h().map_rect_bbox(Rect::new(0.0, 0.0, 10.0, 20.0)).unwrap();
for p in [Point::new(0.0, 0.0), Point::new(10.0, 20.0), Point::new(10.0, 0.0), Point::new(5.0, 10.0)] {
let q = h().apply(p).unwrap();
assert!(b.inflate(1e-9, 1e-9).contains(q));
}
}
}
+19 -1
View File
@@ -38,6 +38,11 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[
"{folder?, zip?, artboards? | range? | fullDocument? | assets?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)",
),
("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"),
(
"tool.setOption",
"Tool Option",
"{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them); kept options last across tool switches",
),
];
fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
@@ -68,6 +73,7 @@ fn tool_key(name: &str) -> Option<ToolKey> {
"[" | "bracketleft" | "openbracket" => ToolKey::BracketLeft,
"]" | "bracketright" | "closebracket" => ToolKey::BracketRight,
"tab" => ToolKey::Tab,
k if k.len() == 1 => ToolKey::Digit(k.parse().ok()?),
_ => return None,
})
}
@@ -113,6 +119,7 @@ impl Headless {
"file.export" => self.export(params),
"file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()),
"tool.select" => self.select_tool(params),
"tool.setOption" => self.session.set_tool_option_cmd(params),
_ => self.session.execute(id, params).map_err(|e| e.to_string()),
}
}
@@ -141,6 +148,7 @@ impl Headless {
self.session.select_tool(&t, self.view).map_err(|e| e.to_string())?;
}
UiRequest::Dialog(k, p) => out.push(json!({"dialog": k, "params": p})),
UiRequest::Status(msg) => out.push(json!({"status": msg})),
}
}
Ok(())
@@ -160,9 +168,14 @@ impl Headless {
let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?;
let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?;
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base);
let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: 1.0 };
let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: PointerEvent::json_pressure(e) };
let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut requests)?;
let hold = PointerEvent::json_hold(e);
if hold > 0.0 {
let reqs = self.session.tool_tick(hold, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut requests)?;
}
}
let sel = self.session.active().map(|d| d.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>()).unwrap_or_default();
Ok(json!({"selection": sel, "tool": self.session.tool_id(), "requests": requests}))
@@ -191,6 +204,11 @@ impl Headless {
self.session.select_tool(t.id, self.view).map_err(|e| e.to_string())?;
return Ok(json!({"handledBy": "tool.select", "tool": t.id}));
}
// Backspace clears the selection as Delete does (as in the desktop app).
if tk == Some(ToolKey::Backspace) && mods == Mods::default() && self.session.active().is_some_and(|d| !d.selection.is_empty()) {
let r = self.exec("edit.clear", &json!({}))?;
return Ok(json!({"handledBy": "command", "command": "edit.clear", "result": r}));
}
if let Some(k) = tk {
let mut out = vec![];
let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?;
+6
View File
@@ -30,6 +30,8 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979";
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_distortkeys;
#[cfg(test)]
mod tests_exportas;
#[cfg(test)]
mod tests_fileio;
@@ -40,6 +42,10 @@ mod tests_gradient;
#[cfg(test)]
mod tests_links;
#[cfg(test)]
mod tests_liquify;
#[cfg(test)]
mod tests_persp;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_svg;
+66
View File
@@ -0,0 +1,66 @@
//! `press_key` with the Width and Puppet Warp tools: Delete and Backspace reach the selected width
//! point or pin, and clear the selected art when the tool has nothing selected.
use serde_json::{Value, json};
use crate::{Backend, Headless, call_tool};
fn ok(h: &mut Headless, name: &str, args: Value) -> Value {
let r = call_tool(h, name, &args);
assert!(!r.is_error, "{name} {args}: {r:?}");
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null)
}
fn run(h: &mut Headless, command: &str, params: Value) -> Value {
ok(h, "run_command", json!({"command": command, "params": params}))
}
fn exists(h: &Headless, id: u64) -> bool {
h.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).is_some()
}
fn click(h: &mut Headless, tool: &str, x: f64, y: f64) {
ok(h, "pointer_gesture", json!({"tool": tool, "events": [{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]}));
}
fn session() -> Headless {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 400, "height": 400}})).unwrap();
h
}
#[test]
fn delete_and_backspace_reach_the_width_tool() {
for key in ["Delete", "Backspace"] {
let mut h = session();
let id = run(&mut h, "shape.line", json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200}))["id"].as_u64().unwrap();
run(&mut h, "stroke.set", json!({"weight": 10}));
run(&mut h, "stroke.widthPoint.set", json!({"id": id, "t": 0.5, "left": 12, "right": 12}));
click(&mut h, "width", 200.0, 200.0);
let r = ok(&mut h, "press_key", json!({"key": key}));
assert_eq!(r["handledBy"], "tool", "{key}: {r}");
let n = h.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.stroke().unwrap().profile.clone().unwrap();
assert_eq!(n.points.len(), 2, "{key} removed the width point");
// With no width point selected the key clears the selected line.
ok(&mut h, "press_key", json!({"key": key}));
assert!(!exists(&h, id), "{key} cleared the line");
}
}
#[test]
fn delete_and_backspace_reach_the_puppet_warp_tool() {
for key in ["Delete", "Backspace"] {
let mut h = session();
let id = run(&mut h, "shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100}))["id"].as_u64().unwrap();
ok(&mut h, "select_tool", json!({"tool": "puppetWarp"}));
let pins = |h: &Headless| h.session.tool_options()["pins"].as_array().map_or(0, Vec::len);
click(&mut h, "puppetWarp", 112.0, 188.0);
let n = pins(&h);
let r = ok(&mut h, "press_key", json!({"key": key}));
assert_eq!(r["handledBy"], "tool", "{key}: {r}");
assert!(exists(&h, id), "{key} kept the art");
assert_eq!(pins(&h), n - 1, "{key} removed the pin");
ok(&mut h, "press_key", json!({"key": key}));
assert!(!exists(&h, id), "{key} cleared the art");
}
}
+64
View File
@@ -0,0 +1,64 @@
//! The Liquify tools through MCP: their options (`tool.setOption`, kept across tool switches) and
//! brush strokes with `pointer_gesture`.
use serde_json::{Value, json};
use crate::{Headless, call_tool};
fn ok(h: &mut Headless, name: &str, args: Value) -> Value {
let r = call_tool(h, name, &args);
assert!(!r.is_error, "{name} {args}: {r:?}");
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null)
}
fn command(h: &mut Headless, command: &str, params: Value) -> Value {
ok(h, "run_command", json!({"command": command, "params": params}))
}
#[test]
fn tool_options_are_set_headless_and_kept_across_tool_switches() {
let mut h = Headless::with_document();
command(&mut h, "tool.select", json!({"tool": "warp"}));
let o = command(&mut h, "tool.setOption", json!({"values": {"width": 64, "detail": 6}}));
assert_eq!((o["width"].as_f64(), o["detail"].as_f64()), (Some(64.0), Some(6.0)));
command(&mut h, "tool.select", json!({"tool": "bloat"}));
let o = command(&mut h, "tool.setOption", json!({}));
assert_eq!((o["tool"].as_str(), o["width"].as_f64(), o["detail"].as_f64()), (Some("bloat"), Some(64.0), Some(2.0)));
command(&mut h, "tool.select", json!({"tool": "warp"}));
assert_eq!(command(&mut h, "tool.setOption", json!({}))["detail"], json!(6.0));
}
#[test]
fn pointer_gestures_carry_pen_pressure() {
let bloat = |pressure: f64| {
let mut h = Headless::with_document();
let id = command(&mut h, "shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 200}))["id"].clone();
command(&mut h, "select.set", json!({"ids": []}));
command(&mut h, "tool.select", json!({"tool": "bloat"}));
command(&mut h, "tool.setOption", json!({"key": "usePressure", "value": true}));
let ev = |kind: &str, y: f64| json!({"kind": kind, "x": 280, "y": y, "pressure": pressure});
ok(&mut h, "pointer_gesture", json!({"events": [ev("down", 150.0), ev("drag", 200.0), ev("up", 250.0)]}));
command(&mut h, "document.node", json!({"id": id}))
};
let (none, full) = (bloat(0.0), bloat(1.0));
assert_eq!(none, bloat(0.0));
assert_ne!(none, full);
}
#[test]
fn holding_still_through_mcp_and_skips_are_reported() {
let pucker = |hold: u64| {
let mut h = Headless::with_document();
let id = command(&mut h, "shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 200}))["id"].clone();
command(&mut h, "text.create", json!({"x": 290, "y": 200, "text": "Hi"}));
command(&mut h, "select.set", json!({"ids": []}));
let ev = |kind: &str| json!({"kind": kind, "x": 290, "y": 200, "holdMs": hold});
let r = ok(&mut h, "pointer_gesture", json!({"tool": "pucker", "events": [ev("down"), ev("up")]}));
(r, command(&mut h, "document.node", json!({"id": id})))
};
let (r, still) = pucker(0);
assert!(r["requests"].as_array().unwrap().iter().any(|q| q["status"].as_str().is_some_and(|m| m.contains("type"))), "{r}");
let (_, held) = pucker(500);
assert_ne!(still, held);
assert_eq!(held, pucker(500).1, "the same hold, the same result");
}
+39
View File
@@ -0,0 +1,39 @@
//! The Perspective Selection tool over MCP: a drag with 5 pressed half-way moves perpendicular to
//! the plane, the arrows nudge in perspective.
use serde_json::{Value, json};
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
use crate::{Headless, call_tool};
fn text(r: &crate::ToolResult) -> Value {
assert!(!r.is_error, "{r:?}");
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
}
fn run(h: &mut Headless, command: &str, params: Value) -> Value {
text(&call_tool(h, "run_command", &json!({"command": command, "params": params})))
}
#[test]
fn perspective_selection_takes_5_and_the_arrows_over_mcp() {
let mut h = Headless::with_document();
run(&mut h, "perspective.grid.preset", json!({"kind": 2}));
let id = run(&mut h, "shape.rectangle", json!({"x": 330, "y": 500, "width": 60, "height": 60}))["id"].as_u64().unwrap();
run(&mut h, "perspective.attach", json!({"ids": [id], "plane": "right"}));
let depth = |h: &Headless| persp::attachment(h.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap()).unwrap();
let doc = &h.session.doc().unwrap().doc;
let g = PerspectiveGrid::from_doc(doc).unwrap();
let (_, _, rect) = g.plane_bounds(doc, &[vectorcraft_doc::NodeId(id)]).unwrap();
let from = g.homography_at(Plane::Right, 0.0).unwrap().apply(rect.center()).unwrap();
let to = g.homography_at(Plane::Right, 30.0).unwrap().apply(rect.center()).unwrap();
let ev = |kind: &str, p: vectorcraft_geom::Point| json!({"kind": kind, "x": p.x, "y": p.y});
text(&call_tool(&mut h, "pointer_gesture", &json!({"tool": "perspectiveSelection", "events": [ev("down", from), ev("drag", to)]})));
assert_eq!(text(&call_tool(&mut h, "press_key", &json!({"key": "5"})))["handledBy"], "tool");
text(&call_tool(&mut h, "pointer_gesture", &json!({"events": [ev("up", to)]})));
let (plane, d) = depth(&h);
assert_eq!(plane, Plane::Right);
assert!((d - 30.0).abs() < 1e-6, "{d}");
assert_eq!(text(&call_tool(&mut h, "press_key", &json!({"key": "Right"})))["handledBy"], "tool");
assert_eq!(h.session.journal.last().unwrap().0, "perspective.nudge");
}
+2
View File
@@ -127,6 +127,8 @@ pub fn tool_definitions() -> Vec<Value> {
"x": num("Document x (pt)"),
"y": num("Document y (pt)"),
"mods": mods_schema(),
"pressure": num("Pen pressure 0..1 (default 1): the Liquify tools' intensity with Use Pressure Pen on"),
"holdMs": num("Milliseconds the pointer then holds still, button down (0..60000): Twirl, Pucker and Bloat keep applying"),
}), &["kind", "x", "y"]),
},
"mods": mods_schema(),
+2 -2
View File
@@ -92,7 +92,7 @@ pub(crate) fn rgb24(img: &Rgba, opacity: f32) -> (Vec<u8>, Vec<u8>) {
// Bottom-up: the first stored row is the image's last.
let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue };
let Some(dst) = bits.get_mut(at..at + img.width as usize * 3) else { continue };
for (d, p) in dst.chunks_exact_mut(3).zip(src.as_chunks::<4>().0) {
for (d, p) in dst.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<4>().0) {
let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32;
d.copy_from_slice(&[over(p[2], a), over(p[1], a), over(p[0], a)]);
}
@@ -107,7 +107,7 @@ pub(crate) fn bgra32(img: &Rgba, opacity: f32) -> (Vec<u8>, Vec<u8>) {
for (y, src) in img.pixels.chunks_exact(row.max(1)).enumerate() {
let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue };
let Some(dst) = bits.get_mut(at..at + row) else { continue };
for (d, p) in dst.chunks_exact_mut(4).zip(src.as_chunks::<4>().0) {
for (d, p) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src.as_chunks::<4>().0) {
let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32;
let pre = |c: u8| ((u32::from(c) * a + 127) / 255) as u8;
d.copy_from_slice(&[pre(p[2]), pre(p[1]), pre(p[0]), a as u8]);
+2 -2
View File
@@ -583,7 +583,7 @@ impl Player {
let mut bp = BezPath::new();
bp.move_to(*first);
if bezier {
for c in rest.chunks_exact(3) {
for c in rest.as_chunks::<3>().0 {
bp.curve_to(c[0], c[1], c[2]);
}
} else {
@@ -636,7 +636,7 @@ impl Player {
}
};
if bezier {
for c in pts.chunks_exact(3) {
for c in pts.as_chunks::<3>().0 {
target.curve_to(xf * c[0], xf * c[1], xf * c[2]);
}
} else {
+1 -1
View File
@@ -314,7 +314,7 @@ fn record(p: &mut Player, kind: u32, rec: &[u8]) -> Option<()> {
let off = r.u32()?;
let s = if kind == 84 {
let raw = slice(rec, off, u32::try_from(n.checked_mul(2)?).ok()?)?;
String::from_utf16_lossy(&raw.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect::<Vec<_>>())
String::from_utf16_lossy(&raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect::<Vec<_>>())
} else {
slice(rec, off, u32::try_from(n).ok()?)?.iter().map(|b| char::from(*b)).collect()
};
+1 -1
View File
@@ -190,7 +190,7 @@ impl<'a> Scene<'a> {
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
}
+1 -1
View File
@@ -68,7 +68,7 @@ pub(crate) fn units_per_inch(w: f64, h: f64) -> u16 {
/// The XOR of the first ten 16-bit words of a placeable header.
pub(crate) fn checksum(header: &[u8]) -> u16 {
header.chunks_exact(2).take(10).fold(0, |acc, w| acc ^ u16::from_le_bytes([w[0], w[1]]))
header.as_chunks::<2>().0.iter().take(10).fold(0, |acc, w| acc ^ u16::from_le_bytes(*w))
}
/// `bp` flattened to within `tol`: each subpath's points and whether it closes.
+178 -9
View File
@@ -1,10 +1,13 @@
//! Illustrator's Pathfinder panel and Shape Builder regions, over an ordered stack of shapes
//! (index 0 = back-most, last = front-most).
use std::collections::HashMap;
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, Point, Shape as _};
use linesweeper::topology::ContourIdx;
use vectorcraft_geom::{FillRule, PathData};
use crate::boolean::{Arrangement, Seg, all_contours_to_path, contours_to_path, fill_bezpath, segs_to_subpath, unite_all};
use crate::boolean::{Arrangement, Multi, Seg, all_contours_to_path, contours_to_path, fill_bezpath, segs_to_subpath, unite_all};
/// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour);
/// results carry the key of the object whose paint they keep.
@@ -142,21 +145,108 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
}
}
/// The faces of `arr`, each distinct coverage mask's in turn (sorted), each face grouped as
/// `arr.contours(|m| m == mask).grouped()` groups it.
///
/// A contour pass walks the whole arrangement, so one pass per mask would cost masks × edges
/// (two blends crossing make thousands of each). Instead the masks go into batches whose faces
/// share no vertex (so no edge either): one pass per batch walks each face's contours exactly as
/// its own mask's pass would, and the mask a contour bounds is the one of its batch at its first
/// point.
fn regions_of(arr: &Arrangement) -> Vec<Region> {
let mut out = Vec::new();
for mask in arr.distinct_masks() {
let c = arr.contours(|m| m == mask.as_slice());
let sources: Vec<usize> = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect();
for g in c.grouped() {
let path = contours_to_path(&c, g, &arr.tidy);
if !path.is_empty() {
out.push(Region { path, sources: sources.clone() });
let masks = arr.distinct_masks();
let ids: HashMap<&[bool], usize> = masks.iter().enumerate().map(|(i, m)| (m.as_slice(), i)).collect();
let id = |w: &Multi| ids.get(arr.mask(w).as_slice()).copied();
let top = &arr.top;
// The masks of the faces around each vertex.
let mut around: HashMap<(u64, u64), Vec<usize>> = HashMap::new();
for s in top.segment_indices() {
let h = s.first_half();
let sides = [id(top.winding_clockwise(h)), id(top.winding_counter_clockwise(h))];
for end in [h, s.second_half()] {
let at = around.entry(vertex_key(top.point(end).to_kurbo())).or_default();
for m in sides.into_iter().flatten() {
if !at.contains(&m) {
at.push(m);
}
}
}
}
let mut near = vec![Vec::new(); masks.len()];
for at in around.values() {
for &a in at {
near[a].extend(at.iter().copied().filter(|&b| b != a));
}
}
// Greedy colouring: each mask takes the first batch none of its neighbours is in.
let mut batch = vec![usize::MAX; masks.len()];
let mut batches = 0;
for m in 0..masks.len() {
let mut taken = vec![false; batches];
for &n in &near[m] {
if let Some(t) = taken.get_mut(batch[n]) {
*t = true;
}
}
batch[m] = taken.iter().position(|t| !t).unwrap_or(batches);
batches = batches.max(batch[m] + 1);
}
let mut faces: Vec<Vec<PathData>> = vec![Vec::new(); masks.len()];
for b in 0..batches {
let c = top.contours(|w| id(w).is_some_and(|m| batch[m] == b));
let owner: Option<Vec<usize>> = c
.contours()
.map(|ct| {
let Some(kurbo::PathEl::MoveTo(p)) = ct.path.elements().first() else { return None };
around.get(&vertex_key(*p))?.iter().copied().find(|&m| batch[m] == b)
})
.collect();
let Some(owner) = owner else {
// Not expected: every contour starts at a vertex of the arrangement. Fall back to a
// pass per mask for this batch.
for (m, mask) in masks.iter().enumerate().filter(|(m, _)| batch[*m] == b) {
let c = arr.contours(|k| k == mask.as_slice());
faces[m].extend(c.grouped().into_iter().map(|g| contours_to_path(&c, g, &arr.tidy)));
}
continue;
};
// A contour belongs under the nearest enclosing contour of the same mask, as in that
// mask's own pass.
let mut children = vec![Vec::new(); owner.len()];
let mut roots = Vec::new();
for (i, &m) in owner.iter().enumerate() {
let mut up = c[ContourIdx(i)].parent;
while let Some(p) = up.filter(|p| owner.get(p.0) != Some(&m)) {
up = c[p].parent;
}
match up {
Some(p) => children[p.0].push(i),
None => roots.push(i),
}
}
for r in roots {
let mut group = Vec::new();
let mut stack = vec![r];
while let Some(i) = stack.pop() {
group.push(ContourIdx(i));
stack.extend(children[i].iter().rev());
}
faces[owner[r]].push(contours_to_path(&c, group, &arr.tidy));
}
}
let mut out = Vec::new();
for (mask, paths) in masks.iter().zip(faces) {
let sources: Vec<usize> = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect();
out.extend(paths.into_iter().filter(|p| !p.is_empty()).map(|path| Region { path, sources: sources.clone() }));
}
out
}
/// A point as a map key (the arrangement's vertices are exact).
fn vertex_key(p: Point) -> (u64, u64) {
(p.x.to_bits(), p.y.to_bits())
}
/// All faces of the planar arrangement of `shapes` (every area covered by at least one shape,
/// split wherever coverage changes).
pub fn regions(shapes: &[Shape]) -> Vec<Region> {
@@ -277,3 +367,82 @@ fn mid_point(c: &[Seg]) -> Point {
}
c[0].c.p0
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use vectorcraft_geom::{Rect, SubPath, shapes};
/// The faces one contour pass per mask finds: what [`regions_of`] must match exactly.
fn regions_one_mask_at_a_time(arr: &Arrangement) -> Vec<Region> {
let mut out = Vec::new();
for mask in arr.distinct_masks() {
let c = arr.contours(|m| m == mask.as_slice());
let sources: Vec<usize> = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect();
for g in c.grouped() {
let path = contours_to_path(&c, g, &arr.tidy);
if !path.is_empty() {
out.push(Region { path, sources: sources.clone() });
}
}
}
out
}
fn same_as_one_mask_at_a_time(shapes: &[Shape]) {
let arr = arrangement(shapes).expect("arrangement");
assert_eq!(regions_of(&arr), regions_one_mask_at_a_time(&arr));
}
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> PathData {
shapes::rectangle(Rect::new(x0, y0, x1, y1))
}
/// Shapes on a coarse grid, so that edges and corners coincide: rectangles, rectangles with
/// a hole, ellipses and polygons that may cross themselves, under either fill rule.
fn arb_shape() -> impl Strategy<Value = Shape> {
let c = || (0..12i32).prop_map(|v| f64::from(v) * 10.0);
let r = (c(), c(), 1..6i32, 1..6i32).prop_map(|(x, y, w, h)| (x, y, x + f64::from(w) * 10.0, y + f64::from(h) * 10.0));
let path = prop_oneof![
r.clone().prop_map(|(x0, y0, x1, y1)| rect(x0, y0, x1, y1)),
r.clone().prop_map(|(x0, y0, x1, y1)| {
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let mut p = rect(x0 - 10.0, y0 - 10.0, x1 + 10.0, y1 + 10.0);
p.subpaths.extend(rect(x0, y0, cx.max(x0 + 5.0), cy.max(y0 + 5.0)).subpaths);
p
}),
r.prop_map(|(x0, y0, x1, y1)| shapes::ellipse(Rect::new(x0, y0, x1, y1))),
prop::collection::vec((c(), c()), 3..7).prop_map(|pts| {
let pts: Vec<Point> = pts.into_iter().map(|(x, y)| Point::new(x, y)).collect();
PathData::single(SubPath::polyline(&pts, true))
}),
];
(path, any::<bool>(), any::<u64>())
.prop_map(|(p, even_odd, key)| Shape::new(p, if even_odd { FillRule::EvenOdd } else { FillRule::NonZero }, key))
}
#[test]
fn crossing_strips_match_one_mask_at_a_time() {
let mut v = Vec::new();
for i in 0..12 {
let x = 10.0 + 20.0 * f64::from(i);
v.push(Shape::new(rect(x, 0.0, x + 4.0, 250.0), FillRule::NonZero, 1));
v.push(Shape::new(rect(0.0, x, 250.0, x + 4.0), FillRule::NonZero, 2));
}
same_as_one_mask_at_a_time(&v);
// Every crossing, and each strip in 13 pieces.
assert_eq!(regions(&v).len(), 12 * 12 + 2 * 12 * 13);
}
proptest! {
#![proptest_config(ProptestConfig { cases: 256, failure_persistence: None, rng_seed: proptest::test_runner::RngSeed::Fixed(0x5eed_9a7f), ..ProptestConfig::default() })]
/// Batching the masks changes nothing: the same regions, in the same order, with the same
/// contours in each.
#[test]
fn batched_regions_match_one_mask_at_a_time(v in prop::collection::vec(arb_shape(), 1..8)) {
same_as_one_mask_at_a_time(&v);
}
}
}
+3 -4
View File
@@ -232,12 +232,11 @@ impl Aes {
prev = self.block(std::array::from_fn(|i| chunk[i] ^ prev[i]));
out.extend_from_slice(&prev);
};
let mut chunks = data.chunks_exact(16);
for c in &mut chunks {
put(std::array::from_fn(|i| c.get(i).copied().unwrap_or_default()), &mut out);
let (chunks, rest) = data.as_chunks::<16>();
for c in chunks {
put(*c, &mut out);
}
if pad {
let rest = chunks.remainder();
let n = (16 - rest.len()) as u8;
put(std::array::from_fn(|i| rest.get(i).copied().unwrap_or(n)), &mut out);
}
+1 -1
View File
@@ -983,7 +983,7 @@ impl Exporter<'_> {
}
// Live blends/envelopes/meshes export their evaluated (expanded) form.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
for c in g.children().into_iter().flatten() {
self.node(s, c, page, false);
}
+1 -1
View File
@@ -166,7 +166,7 @@ fn anti_alias_none_is_the_same_on_every_thread_count() {
};
let (st, mt) = (render(0), render(3));
for img in [&st, &mt] {
assert!(img.to_straight().chunks_exact(4).all(|p| p[3] == 0 || p[3] == 255), "hard edges");
assert!(img.to_straight().as_chunks::<4>().0.iter().all(|p| p[3] == 0 || p[3] == 255), "hard edges");
}
let worst = st.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "max channel difference {worst}");
+1 -1
View File
@@ -52,7 +52,7 @@ pub(crate) fn has_fx(n: &Node) -> bool {
/// symbol instances, live objects; Crop Marks and colour adjustments on anything), or is it a group
/// or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
pub(crate) fn has_object_fx(n: &Node) -> bool {
(effects::needs_outline(n) && visible(&n.appearance.effects))
(effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some()))
|| effects::has_container_appearance(n)
|| effects::has_crop_marks(n)
|| effects::has_adjustment(n)
+44 -3
View File
@@ -834,6 +834,16 @@ impl Renderer {
&& let Some(region) = self.clip_of(clip)
{
let (fill, stroke) = self.clip_paint_of(clip);
// An image inside its own frame painted straight into the clipping path, without
// a clip layer (envelopes cut distorted images into many such pieces).
if let ([image], None, None) = (rest, &fill, &stroke)
&& let NodeKind::Image(im) = &image.kind
&& plain_image(image, im, &region.0)
{
self.draw_image_in(ctx, f, im, Some(&region));
self.stats.drawn += 1;
return;
}
let blends = self.blends_through(n);
let bounds = if blends { Some(region.0.bounding_box()) } else { None };
let comp = Composite { clip: Some((&region.0, region.1)), blends, bounds, ..Default::default() };
@@ -1264,6 +1274,12 @@ impl Renderer {
}
fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) {
self.draw_image_in(ctx, f, im, None);
}
/// Draw an image, or with `area` (a region in the document inside the image's frame, see
/// [`plain_image`]) the image painted into that region.
fn draw_image_in(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject, area: Option<&(BezPath, FillRule)>) {
let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64);
// Outline mode draws the image's frame, and with Document Setup → Show Images in Outline
// Mode its pixels in greyscale under the frame.
@@ -1278,10 +1294,20 @@ impl Renderer {
let pm = if outline { pm } else { self.ink_image(&cache_key, &pm, f.ink) };
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
ctx.set_transform(f.view * im.xf);
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
match area {
Some((bp, rule)) => {
ctx.set_transform(f.view);
ctx.set_paint_transform(im.xf * Affine::scale_non_uniform(sx, sy));
ctx.set_fill_rule(fill_rule(*rule));
ctx.fill_path(bp);
}
None => {
ctx.set_transform(f.view * im.xf);
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
}
}
ctx.reset_paint_transform();
}
if outline {
@@ -1352,6 +1378,21 @@ impl Renderer {
}
}
/// Can image node `n` (`im`) clipped by `region` be painted straight into the region: visible,
/// with no transparency, effects or paint of its own, and the region inside the image's frame
/// (within a pixel), so no paint past the image's edge shows?
fn plain_image(n: &Node, im: &vectorcraft_doc::ImageObject, region: &BezPath) -> bool {
if !(n.visible && n.has_default_transparency() && n.appearance.items.is_empty() && n.appearance.effects.is_empty()) {
return false;
}
let det = im.xf.determinant();
if !(det.abs() > 1e-12 && det.is_finite()) {
return false;
}
let b = im.xf.inverse().transform_rect_bbox(region.bounding_box());
b.x0 >= -1.0 && b.y0 >= -1.0 && b.x1 <= im.width as f64 + 1.0 && b.y1 <= im.height as f64 + 1.0
}
/// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask
/// art black (clip) or white (no clip), optionally inverted.
fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 {
+58 -38
View File
@@ -1,8 +1,10 @@
//! Live objects in the renderer.
//!
//! Blends and envelopes are evaluated by `vectorcraft_doc::live` and the resulting objects cached
//! by `Arc` identity of the live node (so the steps keep stable `Arc`s and hit the geometry cache
//! frame after frame). Gradient meshes are tessellated into many small solid-colour quads (vello
//! Blends and envelopes are evaluated by `vectorcraft_doc::live` (through
//! `vectorcraft_effects::expand_live`, as the exporters do) and the resulting objects cached by
//! `Arc` identity of the live node and of the symbols and patterns it draws (so the steps keep
//! stable `Arc`s and hit the geometry cache frame after frame, and editing a symbol redraws an
//! envelope around an instance of it). Gradient meshes are tessellated into many small solid-colour quads (vello
//! has no mesh shading), with the density chosen from the on-screen patch size; each quad is
//! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams
//! show.
@@ -10,13 +12,13 @@
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::live::{self, GradientMesh, MeshQuad};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{BezPath, PathData, Vec2};
use vectorcraft_doc::live::{GradientMesh, MeshQuad};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::{BezPath, Vec2};
use vello_cpu::RenderContext;
use vello_cpu::peniko;
use crate::{Frame, Renderer, text_geom};
use crate::{Frame, Renderer};
type Expanded = Arc<Vec<Arc<Node>>>;
type MeshEntry = (Arc<Node>, Arc<Vec<MeshQuad>>, u64);
@@ -24,7 +26,7 @@ type MeshEntry = (Arc<Node>, Arc<Vec<MeshQuad>>, u64);
/// Per-renderer cache of evaluated live objects.
#[derive(Default)]
pub(crate) struct LiveCache {
expanded: HashMap<usize, (Arc<Node>, Expanded, u64)>,
expanded: HashMap<usize, (Arc<Node>, Expanded, u64, u64)>,
meshes: HashMap<(usize, usize), MeshEntry>,
stamp: u64,
}
@@ -41,29 +43,34 @@ impl LiveCache {
}
}
/// Text converted to one outline path (for envelopes): glyphs in document space, painted with the
/// first run's fill/stroke.
pub(crate) fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
let g = text_geom(t);
let mut bp = g.all.clone();
bp.apply_affine(t.xf);
let mut out = Node::path(n.id, PathData::from_bezpath(&bp), n.appearance.clone());
if out.appearance.items.is_empty()
&& let Some(r) = t.runs.first()
{
out.appearance = r.style.appearance();
}
out.opacity = n.opacity;
out.blend = n.blend;
Some(out)
/// One level of evaluation of a live node (blend steps, envelope result with type outlined run by
/// run, mesh → flat pieces) without a document (symbol instances and pattern tiles in envelopes
/// stay as they are). Non-live nodes return themselves.
pub fn expand_live(n: &Node) -> Vec<Node> {
crate::effects::expand_live(None, n)
}
/// Evaluate a live node one level (blend steps / envelope result, text outlined; mesh → flat
/// pieces). Non-live nodes return themselves.
pub fn expand_live(n: &Node) -> Vec<Node> {
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
live::expand_live_with(n, Some(hook))
/// What an evaluated live node depends on besides itself: the art of the symbols and the patterns
/// it draws (their `Arc`s outlive document copies, so this changes only when one is edited).
fn resources_key(doc: &Document, n: &Node) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
n.walk(&mut |c| {
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind
&& let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol)
{
(Arc::as_ptr(&s.art) as usize).hash(&mut h);
}
for i in &c.appearance.items {
if let vectorcraft_color::Paint::Pattern { pattern, .. } = i.paint()
&& let Some(def) = doc.pattern(pattern)
{
def.art.iter().for_each(|a| (Arc::as_ptr(a) as usize).hash(&mut h));
[def.tile.x0, def.tile.y0, def.tile.x1, def.tile.y1].iter().for_each(|v| v.to_bits().hash(&mut h));
}
}
});
h.finish()
}
/// Subdivisions per patch for a patch about `size_px` device pixels across.
@@ -73,22 +80,23 @@ fn mesh_level(m: &GradientMesh, bounds_px: f64) -> usize {
}
impl Renderer {
fn live_expanded(&mut self, a: &Arc<Node>, cache: bool) -> Expanded {
fn live_expanded(&mut self, doc: &Document, a: &Arc<Node>, cache: bool) -> Expanded {
let key = Arc::as_ptr(a) as usize;
let resources = resources_key(doc, a);
if cache
&& let Some(e) = self.live.expanded.get_mut(&key)
&& Arc::ptr_eq(&e.0, a)
&& e.3 == resources
{
e.2 = self.live.stamp;
return e.1.clone();
}
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let v: Expanded = match crate::effects::pathfinder_children(a, Some(hook)) {
let v: Expanded = match crate::effects::pathfinder_children(a, crate::effects::text_outliner()) {
Some(children) => Arc::new(children),
None => Arc::new(expand_live(a).into_iter().map(Arc::new).collect()),
None => Arc::new(crate::effects::expand_live(Some(doc), a).into_iter().map(Arc::new).collect()),
};
if cache {
self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp));
self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp, resources));
}
v
}
@@ -114,7 +122,13 @@ impl Renderer {
let stamp = self.stamp;
self.live.tick(stamp);
let opacity = self.opacity_of(a);
if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate) {
// A knockout blend's steps knock each other out, as in the exports (opaque ones can't show it).
let knockout = !f.opts.outline
&& matches!(a.kind, NodeKind::Blend { .. })
&& a.knockout.resolve(self.knockout)
&& vectorcraft_doc::live::steps_knockout_shows(&self.live_expanded(f.doc, a, true));
let enclosing = std::mem::replace(&mut self.knockout, knockout);
if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate || knockout) {
let blends = self.blends_through(a);
let bounds = if blends { self.bounds_of(a) } else { None };
let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() };
@@ -122,6 +136,7 @@ impl Renderer {
} else {
self.draw_live_body(ctx, f, a, true);
}
self.knockout = enclosing;
self.stats.drawn += 1;
}
@@ -130,9 +145,14 @@ impl Renderer {
match &a.kind {
NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache),
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => {
let items = self.live_expanded(a, cache);
for c in items.iter() {
self.draw_arc(ctx, f, c);
let items = self.live_expanded(f.doc, a, cache);
// The flag holds while this blend is drawn (see `draw_live`).
if self.knockout && matches!(a.kind, NodeKind::Blend { .. }) {
self.draw_knockout(ctx, f, &items);
} else {
for c in items.iter() {
self.draw_arc(ctx, f, c);
}
}
}
_ => {}
+64 -10
View File
@@ -67,6 +67,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
symbols: HashMap::new(),
symbol_nest: 0,
fonts: Vec::new(),
shared_images: HashMap::new(),
};
w.assign_name_ids();
// Page Isolated Blending / Page Knockout Group: the page content is one isolated group.
@@ -247,6 +248,9 @@ struct Writer<'a> {
symbol_nest: u32,
/// The characters type uses from each face ([`ExportOptions::embed_fonts`]).
fonts: Vec<FontUse>,
/// Images written once in the defs and drawn with `<use>`, by key and size: the pieces an
/// envelope cuts a distorted image into share its pixels.
shared_images: HashMap<(String, u32, u32), String>,
}
/// The characters type uses from one face, under one `@font-face` description: the family the
@@ -433,6 +437,58 @@ impl Writer<'_> {
self.defs.push('\n');
}
}
/// The `href` of image `im`: its file with [`ImageMode::Link`] (or when its pixels are
/// missing), else its bytes. `None` when it has neither.
fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option<String> {
let (doc, link) = (self.doc, self.opts.images == ImageMode::Link);
match (im.link.as_ref().filter(|_| link), doc.images.get(&im.key)) {
(Some(l), _) => Some(l.path.clone()),
(None, Some(b)) if !b.bytes.is_empty() => Some(self.blob_href(b)),
_ => im.link.as_ref().map(|l| l.path.clone()),
}
}
/// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs
/// once; each piece then draws them with `<use>`.
fn share_images(&mut self, art: &Node) {
let mut seen: HashMap<(String, u32, u32), usize> = HashMap::new();
art.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind {
*seen.entry((im.key.clone(), im.width, im.height)).or_default() += 1;
}
});
let mut repeated: Vec<_> = seen.into_iter().filter(|(k, n)| *n > 1 && !self.shared_images.contains_key(k)).map(|(k, _)| k).collect();
repeated.sort();
for (key, width, height) in repeated {
let link = self.linked_of(art, &key);
let im = vectorcraft_doc::ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link, placement: Default::default() };
let Some(href) = self.image_href(&im) else { continue };
let id = self.fresh_id("image");
self.def(
0,
&format!(
"<image id=\"{id}\" width=\"{width}\" height=\"{height}\" preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
xml_escape(&href)
),
);
self.shared_images.insert((key, width, height), id);
}
}
/// The link of the first image with blob `key` in `art`.
fn linked_of(&self, art: &Node, key: &str) -> Option<vectorcraft_doc::LinkInfo> {
let mut link = None;
art.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind
&& im.key == key
&& link.is_none()
{
link = im.link.clone();
}
});
link
}
fn fresh_id(&mut self, prefix: &str) -> String {
let mut i = 1;
loop {
@@ -1233,18 +1289,15 @@ impl Writer<'_> {
}
NodeKind::Text(t) => self.text_node(n, t),
NodeKind::Image(im) => {
let (doc, link) = (self.doc, self.opts.images == ImageMode::Link);
let href = match (im.link.as_ref().filter(|_| link), doc.images.get(&im.key)) {
(Some(l), _) => l.path.clone(),
(None, Some(b)) if !b.bytes.is_empty() => self.blob_href(b),
_ => match &im.link {
Some(l) => l.path.clone(),
None => return,
},
};
let id = self.id_attr(n);
let m = self.matrix(self.xf * im.xf);
let a = self.attrs(&css::transparency(n));
if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) {
let shared = shared.clone();
self.line(&format!("<use{id} transform=\"{m}\"{a} xlink:href=\"#{shared}\"/>"));
return;
}
let Some(href) = self.image_href(im) else { return };
self.line(&format!(
"<image{id} width=\"{}\" height=\"{}\" transform=\"{m}\" preserveAspectRatio=\"none\"{a} xlink:href=\"{}\"/>",
im.width,
@@ -1290,7 +1343,8 @@ impl Writer<'_> {
}
// Live blends/envelopes/meshes export their evaluated (expanded) form.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
self.share_images(&g);
self.node_body(&g);
}
}
+2 -2
View File
@@ -259,7 +259,7 @@ fn file_face_names(path: &Path) -> Vec<(String, String)> {
// A collection lists where each face's table directory starts.
let starts: Vec<u32> = if head.starts_with(b"ttcf") {
let n = be32(&head, 8).unwrap_or(0).min(MAX_FACES) as usize;
read_at(12, n * 4).map(|b| b.chunks_exact(4).filter_map(|c| be32(c, 0)).collect()).unwrap_or_default()
read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default()
} else {
vec![0]
};
@@ -269,7 +269,7 @@ fn file_face_names(path: &Path) -> Vec<(String, String)> {
let dir = read_at(start.into(), 12)?;
let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize;
let records = read_at(u64::from(start) + 12, tables * 16)?;
let rec = records.chunks_exact(16).find(|r| r.starts_with(b"name"))?;
let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?;
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
if len > MAX_NAME_TABLE {
return None;
+192 -9
View File
@@ -4,35 +4,114 @@
//! path's anchors on that segment, drag to move selected anchors, drag a direction handle to
//! reshape, marquee to select anchors, drag a live rectangle's corner widget to round its corners.
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group.
//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its
//! points (a key object on a point moves with it) and, once a point is clicked, its handles; and
//! the points of selected gradient meshes and mesh envelopes and their handles ([`MeshEdit`]).
use serde_json::{Value, json};
use vectorcraft_doc::hit::hit_test;
use vectorcraft_doc::{AnchorRef, NodeId, NodeKind};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_geom::{PathData, Point, Rect};
use crate::bbox::move_delta;
use crate::corners::{CornerDrag, over_widget};
use crate::meshedit::MeshEdit;
use crate::select::matrix_json;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Debug)]
enum State {
Idle,
MoveAnchors { start: Point, began: bool },
MoveObject { start: Point, began: bool },
Handle { id: NodeId, si: usize, ai: usize, out: bool },
Marquee { start: Point, cur: Point, add: bool },
MoveAnchors {
start: Point,
began: bool,
},
MoveObject {
start: Point,
began: bool,
},
Handle {
id: NodeId,
si: usize,
ai: usize,
out: bool,
},
Marquee {
start: Point,
cur: Point,
add: bool,
},
Corner(CornerDrag),
/// Dragging a point of a blend's spine from `from`.
SpinePoint {
id: NodeId,
anchor: usize,
from: Point,
start: Point,
began: bool,
},
/// Dragging a handle of the clicked spine point.
SpineHandle {
id: NodeId,
anchor: usize,
out: bool,
},
/// Dragging a mesh point or handle ([`MeshEdit`]).
Mesh,
}
pub struct DirectSelectionTool {
group: bool,
state: State,
/// The spine point last clicked (blend, anchor): its handles show and can be dragged.
spine: Option<(NodeId, usize)>,
mesh: MeshEdit,
}
impl DirectSelectionTool {
pub fn new(group: bool) -> Self {
Self { group, state: State::Idle }
Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default() }
}
}
/// The spines of the editable blends, topmost first: (blend, spine).
pub(crate) fn spines(cx: &ToolContext) -> Vec<(NodeId, PathData)> {
let mut v = vec![];
cx.doc.walk(|n| {
if let NodeKind::Blend { children, spec } = &n.kind
&& let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec)
{
v.push((n.id, path));
}
});
v.reverse();
v.retain(|(id, _)| cx.doc.is_editable(*id));
v
}
/// The spine point under `p`: (blend, anchor, where it is).
fn hit_spine_point(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, Point)> {
spines(cx).into_iter().find_map(|(id, path)| {
let (_, ai, a) = path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol)?;
Some((id, ai, a.p))
})
}
/// The spine point `sel` and whether `p` is on its outgoing handle's end (`Some(true)`) or its
/// incoming one's.
fn hit_spine_handle(cx: &ToolContext, sel: (NodeId, usize), p: Point, tol: f64) -> Option<bool> {
let a = spine_anchor(cx, sel)?;
if a.has_out() && a.h_out.distance(p) <= tol {
return Some(true);
}
(a.has_in() && a.h_in.distance(p) <= tol).then_some(false)
}
/// Anchor `sel.1` of blend `sel.0`'s spine.
fn spine_anchor(cx: &ToolContext, (id, ai): (NodeId, usize)) -> Option<vectorcraft_geom::Anchor> {
match &cx.doc.node(id)?.kind {
NodeKind::Blend { children, spec } => vectorcraft_doc::live::blend_spine(children, spec)?.0.subpaths.first()?.anchors.get(ai).copied(),
_ => None,
}
}
@@ -123,10 +202,27 @@ impl Tool for DirectSelectionTool {
self.state = State::Corner(c);
return vec![];
}
if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id))
&& let Some(out) = hit_spine_handle(cx, sel, p, tol)
{
self.state = State::SpineHandle { id: sel.0, anchor: sel.1, out };
return vec![Action::Begin("Reshape Spine".into())];
}
self.spine = None;
if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) {
self.state = State::Mesh;
return self.mesh.press(g);
}
self.mesh.unfocus();
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
self.state = State::Handle { id, si, ai, out };
return vec![Action::Begin("Reshape".into())];
}
if let Some((id, anchor, from)) = hit_spine_point(cx, p, tol) {
self.spine = Some((id, anchor));
self.state = State::SpinePoint { id, anchor, from, start: p, began: false };
return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
if let Some((id, si, ai)) = hit_anchor(cx, p, tol, false) {
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
self.state = State::MoveAnchors { start: p, began: false };
@@ -187,6 +283,28 @@ impl Tool for DirectSelectionTool {
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
)]
}
(PointerKind::Drag, State::SpinePoint { id, anchor, from, start, began }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Reshape Spine".into()));
self.state = State::SpinePoint { id, anchor, from, start, began: true };
}
let q = from + move_delta(start, p, ev.mods.shift);
out.push(Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y})));
out
}
(PointerKind::Drag, State::SpineHandle { id, anchor, out }) => vec![Action::Preview(
"object.blend.spine.moveAnchor".into(),
json!({"id": id.0, "anchor": anchor, "handle": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
)],
(PointerKind::Drag, State::Mesh) => self.mesh.drag_to(p).unwrap_or_default(),
(PointerKind::Up, State::Mesh) => {
self.state = State::Idle;
self.mesh.release().unwrap_or_default()
}
(PointerKind::Drag, State::Marquee { start, add, .. }) => {
self.state = State::Marquee { start, cur: p, add };
vec![]
@@ -200,11 +318,11 @@ impl Tool for DirectSelectionTool {
self.state = State::Idle;
c.finish()
}
(PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. }) => {
(PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. }) => {
self.state = State::Idle;
if began { vec![Action::Commit] } else { vec![] }
}
(PointerKind::Up, State::Handle { .. }) => {
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
self.state = State::Idle;
vec![Action::Commit]
}
@@ -235,7 +353,11 @@ impl Tool for DirectSelectionTool {
match &self.state {
State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
State::Corner(c) => c.overlays(cx),
_ => vec![],
_ => {
let mut out = self.spine_overlays(cx);
out.extend(self.mesh.overlays(cx));
out
}
}
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
@@ -246,12 +368,73 @@ impl Tool for DirectSelectionTool {
}
}
impl DirectSelectionTool {
/// The clicked spine point, filled, with its handles.
fn spine_overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) else { return vec![] };
let Some(a) = spine_anchor(cx, sel) else { return vec![] };
let color = cx.doc.layer_color(sel.0);
let mut out = vec![];
for h in [a.h_in, a.h_out].into_iter().filter(|h| h.distance(a.p) > 1e-6) {
out.push(Overlay::Line { a: a.p, b: h, color, dashed: false });
out.push(Overlay::Handle { p: h, color });
}
out.push(Overlay::Anchor { p: a.p, color, filled: true, size: 5.0 });
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
/// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected.
fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let key = |id: NodeId, x: f64| {
let r = vectorcraft_geom::shapes::rectangle(Rect::new(x, 300.0, x + 20.0, 320.0));
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
};
let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let b = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() });
d.insert(Some(l), 1, b).unwrap();
(d, g, k1)
}
#[test]
fn dragging_a_spine_point_moves_it() {
let (d, g, _) = blend_doc();
let mut s = Selection::default();
s.set([g]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 211.0, 310.0)), vec![]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 221.0, 350.0));
assert_eq!(a[0], Action::Begin("Reshape Spine".into()));
assert_eq!(a[1], Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": g.0, "anchor": 1, "x": 220.0, "y": 350.0})));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 221.0, 350.0)), vec![Action::Commit]);
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows");
}
#[test]
fn direct_and_group_selection_pick_blend_keys() {
let (d, g, k1) = blend_doc();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let a = DirectSelectionTool::new(false).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0));
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]);
let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0));
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]);
// A click on a step (between the keys) still selects the blend.
let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 304.0));
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]);
}
#[test]
fn click_anchor_selects_it() {
let (d, id) = doc_with_rect();
+24 -13
View File
@@ -20,14 +20,14 @@ mod width;
use std::sync::Arc;
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_geom::{Homography, Point};
use crate::Tool;
pub use liquify::LiquifyTool;
pub use perspective::{PerspectiveGridTool, PerspectiveSelectionTool};
pub use puppet::{PuppetWarpTool, mesh_for, mesh_input};
pub use width::WidthTool;
pub use puppet::{PinSet, PuppetWarpTool, mesh_for, mesh_input, rest_and_pins, warp_from_rest};
pub use width::{WidthTool, stroke_owner};
/// Create a distortion tool by id (None = not one of ours).
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
@@ -46,6 +46,24 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
/// editable points (text, images, symbols, meshes, live objects) get the approximation at their
/// centre. Live shapes become plain paths.
pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
warp_node(n, f, None);
}
/// [`warp_node_with`] for the projective map `h` (perspective): type and symbol instances keep
/// their flat art and are drawn through `h` (composed with the projection they had).
pub fn project_node(n: &mut Node, h: &Homography) {
warp_node(n, &|p| h.apply(p).unwrap_or(p), Some(h));
}
fn warp_node(n: &mut Node, f: &dyn Fn(Point) -> Point, h: Option<&Homography>) {
if let Some(h) = h
&& matches!(n.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. })
{
let rec = n.perspective.get_or_insert_with(Default::default);
let had = rec.homography().unwrap_or(Homography::IDENTITY);
rec.projection = Some(h.then_after(&had).to_array());
return;
}
let bounds = n.geometric_bounds();
// Finite-difference step: 5% of the object (at least half a point).
let eps = bounds.map_or(0.5, |b| (b.width().max(b.height()) * 0.05).max(0.5));
@@ -66,7 +84,7 @@ pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
| NodeKind::Compound { children, .. }
| NodeKind::Blend { children, .. } => {
for c in children.iter_mut() {
warp_node_with(Arc::make_mut(c), f);
warp_node(Arc::make_mut(c), f, h);
}
}
_ => {
@@ -79,14 +97,7 @@ pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
n.appearance.warp_gradients(&|p| affine_near(f, p, eps));
}
/// The affine map that best matches `f` near `p` (finite differences with step `eps`).
pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine {
let o = f(p);
let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps);
let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps);
let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]);
Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2())
}
pub use vectorcraft_doc::live::affine_near;
/// Every anchor and handle position in `n` (for meshes and bounds).
pub fn collect_points(n: &Node, out: &mut Vec<Point>) {
@@ -127,7 +138,7 @@ mod tests {
use super::*;
use vectorcraft_color::{Gradient, GradientPaint, Paint};
use vectorcraft_doc::Appearance;
use vectorcraft_geom::{PathData, Rect, shapes};
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
#[test]
fn create_covers_all_distort_tools() {
+267 -34
View File
@@ -132,7 +132,8 @@ impl Mesh {
/// Build a mesh covering `bounds` restricted to the region of `outlines` (filled interiors of
/// closed subpaths plus a band around every path). With no outlines, the whole bounds.
pub fn build(outlines: &[BezPath], bounds: Rect, opt: MeshOptions) -> Mesh {
let side = bounds.width().max(bounds.height()).max(1e-3);
// The cells cover the expanded art, so tiny art with a wide Expand stays a small grid.
let side = (bounds.width().max(bounds.height()) + 2.0 * opt.expand.max(0.0)).max(1e-3);
let cell = (side / opt.resolution.max(1) as f64).max(1e-3);
let pad = opt.expand.max(0.0) + cell * 0.01;
let b = bounds.inflate(pad, pad);
@@ -267,6 +268,99 @@ impl Mesh {
let [a, b, c] = self.tris[t].map(|i| pos[i]);
(b - a).cross(c - a) / 2.0
}
/// Is `p` (rest space) on the mesh?
pub fn contains(&self, p: Point) -> bool {
self.cell_at(p).is_some_and(|k| self.cells.get(k).is_some_and(Option::is_some))
}
/// The grid cell under `p`, if inside the grid.
fn cell_at(&self, p: Point) -> Option<usize> {
let fi = ((p.x - self.origin.x) / self.cell).floor();
let fj = ((p.y - self.origin.y) / self.cell).floor();
(fi >= 0.0 && fj >= 0.0 && fi < self.nx as f64 && fj < self.ny as f64).then(|| fj as usize * self.nx + fi as usize)
}
/// The rest-space point that the `deformed` mesh shows at `q` (None: `q` is off the mesh).
/// Where the warp folds the mesh over itself, the triangle `q` is deepest inside wins.
pub fn unmap(&self, deformed: &[Point], q: Point) -> Option<Point> {
let mut best: Option<(f64, Point)> = None;
for t in &self.tris {
let [a, b, c] = t.map(|i| deformed.get(i).copied().unwrap_or(self.verts[i]));
let den = (b - a).cross(c - a);
if den.abs() < 1e-18 {
continue;
}
let l1 = (q - a).cross(c - a) / den;
let l2 = (b - a).cross(q - a) / den;
let w = [1.0 - l1 - l2, l1, l2];
let depth = w.iter().copied().fold(f64::MAX, f64::min);
if depth >= -1e-9 && best.is_none_or(|(d, _)| depth > d) {
let [ra, rb, rc] = t.map(|i| self.verts[i].to_vec2());
best = Some((depth, (ra * w[0] + rb * w[1] + rc * w[2]).to_point()));
}
}
best.map(|b| b.1)
}
/// How far (radians) the `deformed` mesh turned the art at rest point `p`.
pub fn turn_at(&self, deformed: &[Point], p: Point) -> Option<f64> {
let (t, _) = self.locate(p)?;
let tri = self.tris.get(t)?;
let cur = [*deformed.get(tri[0])?, *deformed.get(tri[1])?, *deformed.get(tri[2])?];
Some(fit_angle(&tri.map(|i| self.verts[i]), &cur))
}
/// Centre of grid cell `k`.
fn cell_centre(&self, k: usize) -> Point {
Point::new(self.origin.x + ((k % self.nx) as f64 + 0.5) * self.cell, self.origin.y + ((k / self.nx) as f64 + 0.5) * self.cell)
}
/// The cells pins go in: those inside the art (or every kept cell when none is).
fn pin_cells(&self) -> Vec<bool> {
let any_core = self.core.iter().any(|c| *c);
self.cells.iter().zip(&self.core).map(|(c, core)| c.is_some() && (*core || !any_core)).collect()
}
/// The 8 neighbours of cell `k` inside the grid, with the step length (in cells).
fn neighbours(&self, k: usize) -> impl Iterator<Item = (usize, f64)> + use<> {
let (nx, ny) = (self.nx as i64, self.ny as i64);
let (i, j) = ((k % self.nx) as i64, (k / self.nx) as i64);
(-1i64..=1).flat_map(move |dj| (-1i64..=1).map(move |di| (di, dj))).filter_map(move |(di, dj)| {
let (a, b) = (i + di, j + dj);
let step = if di != 0 && dj != 0 { std::f64::consts::SQRT_2 } else { 1.0 };
((di, dj) != (0, 0) && a >= 0 && b >= 0 && a < nx && b < ny).then_some(((b * nx + a) as usize, step))
})
}
/// Distances (in cells, through the cells where `inside`) from `sources` (cells with their
/// start distances) to every cell; cells out of reach stay at infinity.
fn geodesic(&self, inside: &[bool], sources: &[(usize, f64)]) -> Vec<f64> {
let mut dist = vec![f64::INFINITY; self.cells.len()];
for &(k, d) in sources {
if let Some(v) = dist.get_mut(k) {
*v = v.min(d);
}
}
let inside = |k: usize| inside.get(k).copied().unwrap_or(false);
// Relax until nothing improves (the grid is small: a few dozen cells a side).
let mut changed = true;
while changed {
changed = false;
for k in 0..dist.len() {
if !inside(k) || !dist[k].is_finite() {
continue;
}
for (m, step) in self.neighbours(k) {
if inside(m) && dist[k] + step < dist[m] - 1e-12 {
dist[m] = dist[k] + step;
changed = true;
}
}
}
}
dist
}
}
/// Winding number of the closed subpaths of `o` at `p` (open subpaths ignored).
@@ -308,6 +402,26 @@ const REG: f64 = 1.0e-7;
pub struct Pin {
pub rest: Point,
pub target: Point,
/// The rotation (radians) the art takes around the pin; `None` leaves it free (the solve
/// picks it), as for a pin that was only moved.
pub angle: Option<f64>,
}
impl Pin {
/// A pin free to turn.
pub fn new(rest: Point, target: Point) -> Self {
Self { rest, target, angle: None }
}
/// The soft constraints this pin puts on the mesh: the pin itself and, when its rotation is
/// held, two points a cell away along the rest axes, turned by the angle around the target.
fn constraints(&self, reach: f64) -> impl Iterator<Item = (Point, Point)> + use<> {
let frame = self.angle.map(|a| {
let (s, c) = a.sin_cos();
[Vec2::new(reach, 0.0), Vec2::new(0.0, reach)].map(|d| (self.rest + d, self.target + Vec2::new(d.x * c - d.y * s, d.x * s + d.y * c)))
});
std::iter::once((self.rest, self.target)).chain(frame.into_iter().flatten())
}
}
/// Run both ARAP steps. Returns the deformed vertex positions (the rest mesh if fewer than one
@@ -317,7 +431,8 @@ pub fn deform(mesh: &Mesh, pins: &[Pin]) -> Vec<Point> {
if n == 0 || pins.is_empty() {
return mesh.verts.clone();
}
let located: Vec<(usize, [f64; 3], Point)> = pins.iter().filter_map(|p| mesh.locate(p.rest).map(|(t, w)| (t, w, p.target))).collect();
let located: Vec<(usize, [f64; 3], Point)> =
pins.iter().flat_map(|p| p.constraints(mesh.cell)).filter_map(|(rest, target)| mesh.locate(rest).map(|(t, w)| (t, w, target))).collect();
let vb = mesh.vertex_band();
// ---- step 1: similarity-invariant error, 2n unknowns interleaved (x0, y0, x1, y1, ...).
let mut g = BandMatrix::new(2 * n, 2 * vb + 1);
@@ -380,16 +495,8 @@ pub fn deform(mesh: &Mesh, pins: &[Pin]) -> Vec<Point> {
for tri in &mesh.tris {
let rest = tri.map(|i| mesh.verts[i]);
let cur = tri.map(|i| step1[i]);
let rc = centroid(&rest);
let cc = centroid(&cur);
let (mut sc, mut ss) = (0.0, 0.0);
for k in 0..3 {
let (r, c) = (rest[k] - rc, cur[k] - cc);
sc += r.dot(c);
ss += r.cross(c);
}
let th = ss.atan2(sc);
let (s, co) = th.sin_cos();
let (rc, cc) = (centroid(&rest), centroid(&cur));
let (s, co) = fit_angle(&rest, &cur).sin_cos();
let fit = rest.map(|r| {
let d = r - rc;
cc + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
@@ -434,24 +541,88 @@ fn centroid(p: &[Point; 3]) -> Point {
Point::new((p[0].x + p[1].x + p[2].x) / 3.0, (p[0].y + p[1].y + p[2].y) / 3.0)
}
/// The rotation (radians) that best fits triangle `rest` onto `cur` (least squares, about their
/// centroids).
fn fit_angle(rest: &[Point; 3], cur: &[Point; 3]) -> f64 {
let (rc, cc) = (centroid(rest), centroid(cur));
let (mut sc, mut ss) = (0.0, 0.0);
for k in 0..3 {
let (r, c) = (rest[k] - rc, cur[k] - cc);
sc += r.dot(c);
ss += r.cross(c);
}
ss.atan2(sc)
}
/// Pick `count` well-spread pin positions inside the mesh (farthest-point sampling over the
/// kept cell centres, starting from the one nearest the centroid).
pub fn auto_pins(mesh: &Mesh, count: usize) -> Vec<Point> {
let centres: Vec<Point> = mesh
.cells
.iter()
.enumerate()
.filter(|(k, c)| c.is_some() && (mesh.core[*k] || !mesh.core.iter().any(|x| *x)))
.map(|(k, _)| Point::new(mesh.origin.x + ((k % mesh.nx) as f64 + 0.5) * mesh.cell, mesh.origin.y + ((k / mesh.nx) as f64 + 0.5) * mesh.cell))
let mut out: Vec<Point> = centre_of(mesh).map(|c| c.1).into_iter().collect();
spread_pins(mesh, &mut out, count);
out
}
/// The centre of the cells pins go in: the cell nearest their centroid, and the centroid itself
/// (that cell's centre when the centroid falls off the art, as in a ring).
fn centre_of(mesh: &Mesh) -> Option<(usize, Point)> {
let cells: Vec<usize> = mesh.pin_cells().iter().enumerate().filter(|(_, c)| **c).map(|(k, _)| k).collect();
let c = (cells.iter().fold(Vec2::ZERO, |a, k| a + mesh.cell_centre(*k).to_vec2()) / cells.len().max(1) as f64).to_point();
let k = cells.into_iter().min_by(|a, b| mesh.cell_centre(*a).distance_squared(c).total_cmp(&mesh.cell_centre(*b).distance_squared(c)))?;
Some((k, if mesh.contains(c) { c } else { mesh.cell_centre(k) }))
}
/// The most pins placed automatically.
const MAX_AUTO_PINS: usize = 12;
/// The pins the Puppet Warp tool places by itself: one at the centre of the art and one at the
/// end of each limb (a part reaching well beyond the art's thickness, like the arms of a star or
/// the ends of a long bar); at least three in all where the art has room.
pub fn default_pins(mesh: &Mesh) -> Vec<Point> {
let Some((centre, middle)) = centre_of(mesh) else { return vec![] };
let inside = mesh.pin_cells();
// Thickness: the largest distance from inside the art to its edge.
let edge: Vec<(usize, f64)> = (0..inside.len())
.filter(|k| inside[*k] && (mesh.neighbours(*k).count() < 8 || mesh.neighbours(*k).any(|(m, _)| !inside[m])))
.map(|k| (k, 0.5))
.collect();
if centres.is_empty() {
return vec![];
let radius = mesh.geodesic(&inside, &edge).into_iter().filter(|d| d.is_finite()).fold(0.0, f64::max);
// Limb ends: the cells farthest from the centre (through the art), well beyond the thickness.
let from_centre = mesh.geodesic(&inside, &[(centre, 0.0)]);
let mut ends: Vec<usize> = (0..inside.len())
.filter(|k| {
let d = from_centre[*k];
inside[*k] && d.is_finite() && d >= 1.5 * radius && mesh.neighbours(*k).all(|(m, _)| !inside[m] || from_centre[m] <= d)
})
.collect();
ends.sort_by(|a, b| from_centre[*b].total_cmp(&from_centre[*a]));
// One pin per limb: ends closer than about the art's width belong to the same limb.
let apart = 2.2 * radius * mesh.cell;
let mut out = vec![middle];
let mut tips: Vec<Point> = vec![];
for k in ends {
let p = mesh.cell_centre(k);
if out.len() >= MAX_AUTO_PINS || tips.iter().any(|q| q.distance(p) < apart) {
continue;
}
tips.push(p);
// The pin goes in the middle of the limb's end: the centroid of the cells about as far out
// near its tip (the tip itself if that falls off the art).
let cap: Vec<Point> = (0..inside.len())
.filter(|m| inside[*m] && from_centre[*m] >= from_centre[k] - radius)
.map(|m| mesh.cell_centre(m))
.filter(|q| q.distance(p) < apart)
.collect();
let mid = (cap.iter().fold(Vec2::ZERO, |a, q| a + q.to_vec2()) / cap.len().max(1) as f64).to_point();
out.push(if mesh.contains(mid) { mid } else { p });
}
let c = centres.iter().fold(Vec2::ZERO, |a, p| a + p.to_vec2()) / centres.len() as f64;
let Some(&first) = centres.iter().min_by(|a, b| a.distance_squared(c.to_point()).total_cmp(&b.distance_squared(c.to_point()))) else {
return vec![];
};
let mut out = vec![first];
spread_pins(mesh, &mut out, 3);
out
}
/// Add pins to `out` until there are `count`, each as far as possible from the others
/// (farthest-point sampling over the cells pins go in).
fn spread_pins(mesh: &Mesh, out: &mut Vec<Point>, count: usize) {
let centres: Vec<Point> = mesh.pin_cells().iter().enumerate().filter(|(_, c)| **c).map(|(k, _)| mesh.cell_centre(k)).collect();
while out.len() < count.min(centres.len()) {
let Some(&next) = centres.iter().max_by(|a, b| {
let da = out.iter().map(|o| o.distance_squared(**a)).fold(f64::MAX, f64::min);
@@ -462,7 +633,6 @@ pub fn auto_pins(mesh: &Mesh, count: usize) -> Vec<Point> {
};
out.push(next);
}
out
}
#[cfg(test)]
@@ -528,7 +698,7 @@ mod tests {
fn pins_at_rest_leave_the_mesh_unchanged() {
let m = rect_mesh();
let pins: Vec<Pin> =
[(20.0, 50.0), (180.0, 50.0), (100.0, 20.0)].iter().map(|&(x, y)| Pin { rest: Point::new(x, y), target: Point::new(x, y) }).collect();
[(20.0, 50.0), (180.0, 50.0), (100.0, 20.0)].iter().map(|&(x, y)| Pin::new(Point::new(x, y), Point::new(x, y))).collect();
let d = deform(&m, &pins);
let err = m.verts.iter().zip(&d).map(|(a, b)| a.distance(*b)).fold(0.0, f64::max);
assert!(err < 1e-4, "{err}");
@@ -541,10 +711,8 @@ mod tests {
let (s, c) = 0.5f64.sin_cos();
Point::new(p.x * c - p.y * s + 30.0, p.x * s + p.y * c - 10.0)
};
let pins: Vec<Pin> = [(20.0, 50.0), (180.0, 50.0), (100.0, 90.0)]
.iter()
.map(|&(x, y)| Pin { rest: Point::new(x, y), target: rot(Point::new(x, y)) })
.collect();
let pins: Vec<Pin> =
[(20.0, 50.0), (180.0, 50.0), (100.0, 90.0)].iter().map(|&(x, y)| Pin::new(Point::new(x, y), rot(Point::new(x, y)))).collect();
let d = deform(&m, &pins);
let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max);
assert!(err < 1e-2, "{err}");
@@ -555,9 +723,9 @@ mod tests {
let m = rect_mesh();
// Hold the left end, lift the right end.
let pins = vec![
Pin { rest: Point::new(10.0, 30.0), target: Point::new(10.0, 30.0) },
Pin { rest: Point::new(10.0, 70.0), target: Point::new(10.0, 70.0) },
Pin { rest: Point::new(190.0, 50.0), target: Point::new(180.0, -30.0) },
Pin::new(Point::new(10.0, 30.0), Point::new(10.0, 30.0)),
Pin::new(Point::new(10.0, 70.0), Point::new(10.0, 70.0)),
Pin::new(Point::new(190.0, 50.0), Point::new(180.0, -30.0)),
];
let d = deform(&m, &pins);
for p in &pins {
@@ -579,6 +747,71 @@ mod tests {
assert!(worst < 0.3, "edge stretch {worst}");
}
#[test]
fn a_held_rotation_turns_the_art_around_its_pin() {
let m = rect_mesh();
let c = Point::new(100.0, 50.0);
let rot = |p: Point| {
let (s, co) = 0.4f64.sin_cos();
let d = p - c;
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
};
// One pin, held at 0.4 rad: the whole shape turns rigidly around it.
let d = deform(&m, &[Pin { angle: Some(0.4), ..Pin::new(c, c) }]);
let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max);
assert!(err < 0.05, "{err}");
// A free pin alone leaves the shape where it is.
let d = deform(&m, &[Pin::new(c, c)]);
assert!(m.verts.iter().zip(&d).all(|(a, b)| a.distance(*b) < 1e-3));
// Held ends: the turned end turns, the other end stays.
let left = Point::new(10.0, 50.0);
let pins = [Pin { angle: Some(0.5), ..Pin::new(left, left) }, Pin::new(Point::new(190.0, 50.0), Point::new(190.0, 50.0))];
let d = deform(&m, &pins);
let near = m.map(&d, Point::new(10.0, 62.0)) - left;
assert!((near.atan2() - (Vec2::new(0.0, 12.0).atan2() + 0.5)).abs() < 0.1, "the left end turned: {near:?}");
assert!(m.map(&d, Point::new(190.0, 80.0)).distance(Point::new(190.0, 80.0)) < 5.0);
}
#[test]
fn default_pins_go_at_the_centre_and_the_end_of_each_limb() {
// A long bar: the centre and both ends.
let m = rect_mesh();
let p = default_pins(&m);
assert_eq!(p.len(), 3, "{p:?}");
assert!(p[0].distance(Point::new(100.0, 50.0)) < 10.0, "{p:?}");
let mut xs: Vec<f64> = p[1..].iter().map(|q| q.x).collect();
xs.sort_by(f64::total_cmp);
assert!(xs[0] < 25.0 && xs[1] > 175.0, "{p:?}");
// A five-pointed star: the centre and its five tips.
let star = vectorcraft_geom::shapes::star(Point::new(100.0, 100.0), 100.0, 35.0, 5, 0.0).to_bezpath();
let sm = Mesh::build(std::slice::from_ref(&star), star.bounding_box(), MeshOptions { resolution: 30, expand: 0.0 });
let p = default_pins(&sm);
assert_eq!(p.len(), 6, "{p:?}");
assert!(p[1..].iter().all(|q| q.distance(Point::new(100.0, 100.0)) > 55.0), "{p:?}");
// A disc has no limbs: the centre, topped up to three.
let disc = vectorcraft_geom::kurbo::Circle::new((50.0, 50.0), 50.0).to_path(0.1);
let dm = Mesh::build(std::slice::from_ref(&disc), disc.bounding_box(), MeshOptions::default());
assert_eq!(default_pins(&dm).len(), 3);
// A point with a wide Expand stays a small grid.
let dot = Mesh::build(&[], Rect::new(5.0, 5.0, 5.0, 5.0), MeshOptions { expand: 1000.0, ..Default::default() });
assert!(dot.verts.len() < 30 * 30 && default_pins(&dot).len() == 3, "{}", dot.verts.len());
}
#[test]
fn unmap_and_turn_read_a_warped_mesh_back() {
let m = rect_mesh();
let rot = |p: Point| {
let (s, c) = 0.3f64.sin_cos();
Point::new(p.x * c - p.y * s + 20.0, p.x * s + p.y * c)
};
let d: Vec<Point> = m.verts.iter().map(|v| rot(*v)).collect();
let p = Point::new(60.0, 40.0);
assert!(m.unmap(&d, rot(p)).unwrap().distance(p) < 1e-6);
assert!(m.unmap(&d, Point::new(-500.0, 0.0)).is_none(), "off the mesh");
assert!((m.turn_at(&d, p).unwrap() - 0.3).abs() < 1e-9);
assert!(m.contains(p) && !m.contains(Point::new(300.0, 50.0)));
}
#[test]
fn auto_pins_are_spread_inside_the_shape() {
let m = rect_mesh();
+343 -80
View File
@@ -1,11 +1,14 @@
//! Liquify tools: Warp (Shift+R), Twirl, Pucker, Bloat, Scallop, Crystallize, Wrinkle.
//!
//! A drag previews `object.liquify {tool, points, width, height, angle, intensity, detail,
//! simplify, rate?, complexity?, horizontal?, vertical?, ids?}` with every pointer sample so far;
//! the engine re-applies the whole stroke to the interaction snapshot with [`apply_stroke`], so the
//! result is a pure function of the parameters (replay reproduces it exactly).
//! simplify, rate?, complexity?, horizontal?, vertical?, affect…?, usePressure?, ids?}` with every
//! pointer sample so far (and its pen pressure while Use Pressure Pen is on), so the result is a
//! pure function of the parameters (replay reproduces it exactly). Holding the brush still with
//! Twirl, Pucker or Bloat repeats the last sample every [`HOLD_EVERY`] seconds of [`Tool::tick`]
//! time: one more dab there each time.
//!
//! The kernel resamples the stroke into dabs spaced a fraction of the brush apart. For each dab it
//! The kernel resamples the stroke into dabs spaced a fraction of the brush apart ([`Dabber`]:
//! more samples only add dabs, so a growing stroke can be applied as it grows). For each dab it
//! first subdivides the segments the brush touches (Detail: more anchors where the brush passes,
//! lines become curves so the result stays smooth), then moves anchors and handles by the tool's
//! displacement field weighted by a smooth falloff `(1 − r²)²` inside the (elliptical, rotated)
@@ -64,6 +67,18 @@ impl LiquifyKind {
Self::Wrinkle => "Wrinkle",
}
}
/// Holding the brush still keeps applying the tool (a repeated stroke sample is one more dab).
pub fn holds(self) -> bool {
matches!(self, Self::Twirl | Self::Pucker | Self::Bloat)
}
/// Has the Simplify option (Scallop, Crystallize and Wrinkle have Brush Affects instead).
pub fn simplifies(self) -> bool {
matches!(self, Self::Warp | Self::Twirl | Self::Pucker | Self::Bloat)
}
/// Has the Brush Affects options (anchor points, in and out tangent handles).
pub fn has_affects(self) -> bool {
!self.simplifies()
}
}
/// Brush and tool options (Warp Tool Options and friends).
@@ -76,10 +91,14 @@ pub struct LiquifyParams {
pub angle: f64,
/// 0..1.
pub intensity: f64,
/// Use Pressure Pen: each stroke sample's pen pressure is the intensity there.
pub use_pressure: bool,
/// 1..10: anchor density added where the brush passes.
pub detail: f64,
/// 0..100: removal of redundant flat anchors afterwards.
/// 0..100: removal of redundant flat anchors afterwards (Warp, Twirl, Pucker, Bloat).
pub simplify: f64,
/// The Simplify checkbox.
pub simplify_on: bool,
/// Twirl rate, degrees (−180..180).
pub rate: f64,
/// Scallop/Crystallize/Wrinkle complexity (0..15).
@@ -100,8 +119,10 @@ impl LiquifyParams {
height: 100.0,
angle: 0.0,
intensity: 0.5,
use_pressure: false,
detail: 2.0,
simplify: 50.0,
simplify_on: true,
rate: 40.0,
complexity: 1.0,
horizontal: 0.0,
@@ -132,23 +153,36 @@ impl LiquifyParams {
s.complexity = f("complexity").unwrap_or(1.0).clamp(0.0, 15.0);
s.horizontal = pct(f("horizontal").unwrap_or(0.0)).clamp(0.0, 1.0);
s.vertical = pct(f("vertical").unwrap_or(1.0)).clamp(0.0, 1.0);
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true);
s.affect_anchors = b("affectAnchors");
s.affect_in = b("affectIn");
s.affect_out = b("affectOut");
let b = |k: &str, d: bool| p.get(k).and_then(Value::as_bool).unwrap_or(d);
s.affect_anchors = b("affectAnchors", true);
s.affect_in = b("affectIn", true);
s.affect_out = b("affectOut", true);
s.use_pressure = b("usePressure", false);
s.simplify_on = b("simplifyOn", true);
Some(s)
}
/// The command parameters (those the kind uses).
pub fn to_json(&self) -> Value {
let mut v = json!({
"tool": self.kind.id(), "width": self.width, "height": self.height, "angle": self.angle,
"intensity": self.intensity, "detail": self.detail, "simplify": self.simplify,
"intensity": self.intensity, "detail": self.detail,
});
if self.use_pressure {
v["usePressure"] = json!(true);
}
if self.kind.simplifies() {
v["simplify"] = json!(self.simplify);
v["simplifyOn"] = json!(self.simplify_on);
} else {
v["complexity"] = json!(self.complexity);
v["affectAnchors"] = json!(self.affect_anchors);
v["affectIn"] = json!(self.affect_in);
v["affectOut"] = json!(self.affect_out);
}
match self.kind {
LiquifyKind::Twirl => v["rate"] = json!(self.rate),
LiquifyKind::Scallop | LiquifyKind::Crystallize => v["complexity"] = json!(self.complexity),
LiquifyKind::Wrinkle => {
v["complexity"] = json!(self.complexity);
v["horizontal"] = json!(self.horizontal);
v["vertical"] = json!(self.vertical);
}
@@ -157,6 +191,36 @@ impl LiquifyParams {
v
}
/// Every option, whichever tool these are (the tool's options: [`Self::to_json`] gives only
/// the parameters its kind uses).
pub fn options_json(&self) -> Value {
let mut v = self.to_json();
for (k, x) in [
("simplify", self.simplify),
("rate", self.rate),
("complexity", self.complexity),
("horizontal", self.horizontal),
("vertical", self.vertical),
] {
v[k] = json!(x);
}
for (k, x) in [
("usePressure", self.use_pressure),
("simplifyOn", self.simplify_on),
("affectAnchors", self.affect_anchors),
("affectIn", self.affect_in),
("affectOut", self.affect_out),
] {
v[k] = json!(x);
}
v
}
/// How far off the chord Simplify removes anchors (0: Simplify is off or not an option).
fn simplify_tolerance(&self) -> f64 {
if self.kind.simplifies() && self.simplify_on { self.simplify / 100.0 * self.detail_spacing() * 0.02 } else { 0.0 }
}
fn radii(&self) -> (f64, f64) {
(self.width / 2.0, self.height / 2.0)
}
@@ -196,34 +260,96 @@ impl LiquifyParams {
/// Resample a stroke polyline into dabs spaced `spacing` apart (the first point is always a dab).
pub fn dabs(points: &[Point], spacing: f64) -> Vec<Point> {
let mut out: Vec<Point> = vec![];
let Some(&first) = points.first() else { return out };
out.push(first);
let mut last = first;
let mut carry = 0.0;
for w in points.windows(2) {
let (a, b) = (w[0], w[1]);
let mut d = Dabber::with(spacing, Some(1.0), false);
for p in points {
d.push((*p, 1.0));
}
d.dabs.iter().chain(d.tail().as_ref()).map(|d| d.c).collect()
}
/// One brush dab: its centre and intensity (0..1).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Dab {
pub c: Point,
pub intensity: f64,
}
/// A stroke sample: the pointer position and its pen pressure (0..1).
pub type Sample = (Point, f64);
/// Most dabs a stroke makes (later samples add none).
const MAX_DABS: usize = 20_000;
/// Resamples a growing stroke into dabs `spacing` apart along it, the first sample always a dab.
/// More samples only add dabs ([`Self::dabs`] is a prefix of what it becomes), so a stroke can be
/// applied as it grows; the stroke's end, when it is off the spacing, is the [`Self::tail`].
#[derive(Clone, Debug, Default)]
pub struct Dabber {
spacing: f64,
/// Every dab's intensity; None: each sample's pressure.
intensity: Option<f64>,
/// A repeated sample is one more dab there (the brush held still).
holds: bool,
samples: Vec<Sample>,
/// Distance from the last dab to the last sample.
carry: f64,
/// The dabs on the spacing so far.
pub dabs: Vec<Dab>,
}
impl Dabber {
/// The dabs of a stroke with brush `prm`.
pub fn new(prm: &LiquifyParams) -> Self {
Self::with(prm.dab_spacing(), (!prm.use_pressure).then_some(prm.intensity), prm.kind.holds())
}
fn with(spacing: f64, intensity: Option<f64>, holds: bool) -> Self {
Self { spacing, intensity, holds, ..Self::default() }
}
/// The samples so far.
pub fn samples(&self) -> &[Sample] {
&self.samples
}
fn dab(&self, c: Point, pressure: f64) -> Dab {
Dab { c, intensity: self.intensity.unwrap_or(pressure.clamp(0.0, 1.0)) }
}
/// Add a sample, and the dabs up to it.
pub fn push(&mut self, (b, pb): Sample) {
let prev = self.samples.last().copied();
self.samples.push((b, pb));
let Some((a, pa)) = prev else {
self.dabs.push(self.dab(b, pb));
return;
};
if self.dabs.len() > MAX_DABS {
return;
}
let len = a.distance(b);
if len < 1e-12 {
continue;
if self.holds {
self.dabs.push(self.dab(b, pb));
self.carry = 0.0;
}
return;
}
let mut s = spacing - carry;
let mut s = self.spacing - self.carry;
while s <= len {
last = a.lerp(b, s / len);
out.push(last);
s += spacing;
}
carry = len - (s - spacing);
if out.len() > 20_000 {
break;
let t = s / len;
self.dabs.push(self.dab(a.lerp(b, t), pa + (pb - pa) * t));
s += self.spacing;
}
self.carry = len - (s - self.spacing);
}
if let Some(&end) = points.last()
&& end.distance(last) > spacing * 0.25
{
out.push(end);
/// The dab at the last sample when it is off the spacing (the stroke ends there).
pub fn tail(&self) -> Option<Dab> {
let &(end, p) = self.samples.last()?;
let last = self.dabs.last()?.c;
(end.distance(last) > self.spacing * 0.25).then(|| self.dab(end, p))
}
out
}
fn hash(mut x: u64) -> u64 {
@@ -404,37 +530,105 @@ fn simplify(sp: &mut SubPath, tol: f64, region: Rect) {
}
}
/// Apply a liquify stroke to `path`. `salt` decorrelates noise between paths. Returns whether
/// anything moved.
/// Apply a liquify stroke (its dabs, at the brush's intensity) to `path`. `salt` decorrelates
/// noise between paths. Returns whether anything moved.
pub fn apply_stroke(path: &mut PathData, dab_pts: &[Point], prm: &LiquifyParams, salt: u64) -> bool {
let spacing = prm.detail_spacing();
let mut changed = false;
let mut region: Option<Rect> = None;
for (si, sp) in path.subpaths.iter_mut().enumerate() {
let salt = hash(salt ^ (si as u64 + 1).wrapping_mul(0x1234_5678_9abc_def1));
for (di, c) in dab_pts.iter().enumerate() {
let prev = if di == 0 { *c } else { dab_pts[di - 1] };
let bb = prm.brush_bounds(*c);
let Some(spb) = sp_bounds(sp) else { continue };
if spb.intersect(bb).area() <= 0.0 && !(spb.width() == 0.0 || spb.height() == 0.0) {
continue;
}
subdivide(sp, prm, *c, spacing);
if apply_dab(sp, prm, *c, prev, di as u64, salt) {
changed = true;
region = Some(region.map_or(bb, |r| r.union(bb)));
}
let dabs: Vec<Dab> = dab_pts.iter().map(|c| Dab { c: *c, intensity: prm.intensity }).collect();
let mut ps = PathStroke::new(std::mem::take(path), salt);
ps.advance(&dabs, prm);
match ps.finish(&dabs, None, prm) {
Some(done) => {
*path = done;
true
}
None => {
*path = ps.path;
false
}
}
if changed && let Some(r) = region {
let tol = prm.simplify / 100.0 * spacing * 0.02;
for sp in &mut path.subpaths {
simplify(sp, tol, r);
}
/// Does the brush box `bb` reach subpath `sp`?
fn touches(sp: &SubPath, bb: Rect) -> bool {
sp_bounds(sp).is_some_and(|spb| spb.intersect(bb).area() > 0.0 || spb.width() == 0.0 || spb.height() == 0.0)
}
/// Apply dab `d` (number `di`, after the dab at `prev`) to every subpath of `path` it reaches.
fn dab_on(path: &mut PathData, di: usize, d: Dab, prev: Point, prm: &LiquifyParams, salt: u64) -> bool {
let prm = LiquifyParams { intensity: d.intensity, ..*prm };
let (bb, spacing) = (prm.brush_bounds(d.c), prm.detail_spacing());
let mut changed = false;
for (si, sp) in path.subpaths.iter_mut().enumerate() {
if !touches(sp, bb) {
continue;
}
subdivide(sp, &prm, d.c, spacing);
changed |= apply_dab(sp, &prm, d.c, prev, di as u64, hash(salt ^ (si as u64 + 1).wrapping_mul(0x1234_5678_9abc_def1)));
}
changed
}
/// A path under a growing stroke: the dabs applied so far (Simplify comes at [`Self::finish`]).
/// Applying a stroke's dabs in any number of steps gives the same path as applying them at once.
#[derive(Clone, Debug)]
pub struct PathStroke {
path: PathData,
/// Decorrelates noise between paths.
salt: u64,
/// Dabs applied.
applied: usize,
/// Where dabs moved something (Simplify works there), None while nothing moved.
region: Option<Rect>,
}
impl PathStroke {
pub fn new(path: PathData, salt: u64) -> Self {
Self { path, salt, applied: 0, region: None }
}
/// Apply the stroke's dabs not applied yet.
pub fn advance(&mut self, dabs: &[Dab], prm: &LiquifyParams) {
for di in self.applied..dabs.len() {
let Some(&d) = dabs.get(di) else { break };
let prev = di.checked_sub(1).and_then(|i| dabs.get(i)).map_or(d.c, |p| p.c);
if dab_on(&mut self.path, di, d, prev, prm, self.salt) {
let bb = prm.brush_bounds(d.c);
self.region = Some(self.region.map_or(bb, |r| r.union(bb)));
}
}
self.applied = self.applied.max(dabs.len());
}
/// The path after the stroke (`dabs`, all applied, then `tail`), simplified; None when
/// nothing moved.
pub fn finish(&self, dabs: &[Dab], tail: Option<Dab>, prm: &LiquifyParams) -> Option<PathData> {
let mut region = self.region;
let mut out = None;
if let Some(t) = tail.filter(|t| self.path.subpaths.iter().any(|sp| touches(sp, prm.brush_bounds(t.c)))) {
let mut p = self.path.clone();
if dab_on(&mut p, dabs.len(), t, dabs.last().map_or(t.c, |d| d.c), prm, self.salt) {
let bb = prm.brush_bounds(t.c);
region = Some(region.map_or(bb, |r| r.union(bb)));
}
out = Some(p);
}
let region = region?;
let mut path = out.unwrap_or_else(|| self.path.clone());
let tol = prm.simplify_tolerance();
if tol > 0.0 {
for sp in &mut path.subpaths {
simplify(sp, tol, region);
}
}
Some(path)
}
}
/// The box around every anchor and handle of `path`: no dab outside it changes the path.
pub fn reach_bounds(path: &PathData) -> Option<Rect> {
path.subpaths.iter().filter_map(sp_bounds).reduce(|a, b| a.union(b))
}
fn sp_bounds(sp: &SubPath) -> Option<Rect> {
let mut it = sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]);
let f = it.next()?;
@@ -443,30 +637,66 @@ fn sp_bounds(sp: &SubPath) -> Option<Rect> {
// ---------- the tool ----------
/// Holding the brush still repeats the stroke's last sample this often (seconds): Twirl, Pucker
/// and Bloat keep applying, scaled by the time held.
pub const HOLD_EVERY: f64 = 0.1;
/// Most repeats one tick adds (a minute held).
const MAX_HOLDS_PER_TICK: u32 = 600;
/// An Alt-drag sizing the brush: where it started and the brush size then.
#[derive(Clone, Copy, Debug)]
struct Sizing {
start: Point,
width: f64,
height: f64,
}
pub struct LiquifyTool {
pub params: LiquifyParams,
points: Vec<Point>,
/// Show Brush Size: the brush outline follows the pointer.
pub show_brush: bool,
/// The stroke's samples (position, pen pressure).
points: Vec<Sample>,
active: bool,
/// Alt-drag resizes the brush from this centre.
sizing: Option<Point>,
sizing: Option<Sizing>,
hover: Option<Point>,
/// Time held still since the last sample or repeat (seconds).
held: f64,
}
impl LiquifyTool {
pub fn new(id: &str) -> Self {
let kind = LiquifyKind::parse(id).unwrap_or(LiquifyKind::Warp);
Self { params: LiquifyParams::new(kind), points: vec![], active: false, sizing: None, hover: None }
Self { params: LiquifyParams::new(kind), show_brush: true, points: vec![], active: false, sizing: None, hover: None, held: 0.0 }
}
/// The command + params for the stroke so far.
/// The command + params for the stroke so far (with each sample's pressure while Use Pressure
/// Pen is on).
pub fn command(&self, cx: &ToolContext) -> (String, Value) {
let mut v = self.params.to_json();
v["points"] = Value::Array(self.points.iter().map(|p| json!([p.x, p.y])).collect());
let pressure = self.params.use_pressure;
v["points"] = Value::Array(self.points.iter().map(|(p, f)| if pressure { json!([p.x, p.y, f]) } else { json!([p.x, p.y]) }).collect());
if !cx.selection.is_empty() {
v["ids"] = crate::json_ids(&cx.selection.objects);
}
("object.liquify".into(), v)
}
/// Alt-drag from `s` to `p`: the brush grows or shrinks from its size at the press (its edge
/// follows the pointer); with Shift it keeps its proportions.
fn resize(&mut self, s: Sizing, p: Point, proportional: bool) {
let (w, h) = (s.width + 2.0 * (p.x - s.start.x), s.height + 2.0 * (p.y - s.start.y));
let (w, h) = if !proportional {
(w, h)
} else if (p.x - s.start.x).abs() >= (p.y - s.start.y).abs() {
(w, s.height * w / s.width)
} else {
(s.width * h / s.height, h)
};
self.params.width = w.clamp(1.0, 10_000.0);
self.params.height = h.clamp(1.0, 10_000.0);
}
}
impl Tool for LiquifyTool {
@@ -476,28 +706,30 @@ impl Tool for LiquifyTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
self.hover = Some(p);
let sample = (p, f64::from(ev.pressure).clamp(0.0, 1.0));
match ev.kind {
PointerKind::Down if ev.mods.alt => {
self.sizing = Some(p);
self.sizing = Some(Sizing { start: p, width: self.params.width, height: self.params.height });
vec![]
}
PointerKind::Down => {
self.points = vec![p];
self.points = vec![sample];
self.active = true;
self.held = 0.0;
let (c, v) = self.command(cx);
vec![Action::Begin(self.params.kind.label().into()), Action::Preview(c, v)]
}
PointerKind::Drag => {
if let Some(c) = self.sizing {
self.params.width = ((p.x - c.x).abs() * 2.0).max(1.0);
self.params.height = if ev.mods.shift { self.params.width } else { ((p.y - c.y).abs() * 2.0).max(1.0) };
self.hover = Some(c);
if let Some(s) = self.sizing {
self.resize(s, p, ev.mods.shift);
self.hover = Some(s.start);
return vec![];
}
if !self.active || self.points.last().is_some_and(|l| l.distance(p) < cx.tol(2.0)) {
if !self.active || self.points.last().is_some_and(|(l, _)| l.distance(p) < cx.tol(2.0)) {
return vec![];
}
self.points.push(p);
self.points.push(sample);
self.held = 0.0;
let (c, v) = self.command(cx);
vec![Action::Preview(c, v)]
}
@@ -516,7 +748,7 @@ impl Tool for LiquifyTool {
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
let Some(c) = self.hover else { return vec![] };
let Some(c) = self.hover.filter(|_| self.show_brush || self.sizing.is_some()) else { return vec![] };
vec![Overlay::Path {
path: ellipse_path(c, self.params.width / 2.0, self.params.height / 2.0, self.params.angle),
color: [0x80, 0x80, 0x80],
@@ -527,18 +759,28 @@ impl Tool for LiquifyTool {
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
}
/// Every option ([`LiquifyParams::options_json`]) and `showBrush`.
fn options(&self) -> Value {
self.params.to_json()
let mut v = self.params.options_json();
v["showBrush"] = json!(self.show_brush);
v
}
fn set_option(&mut self, key: &str, value: &Value) {
let mut v = self.params.to_json();
v[key] = value.clone();
if key == "diameter" {
v["width"] = value.clone();
v["height"] = value.clone();
}
if let Some(p) = LiquifyParams::from_json(&v) {
self.params = p;
match key {
// The kind is the tool's: another tool is another tool.
"tool" => {}
"showBrush" => self.show_brush = value.as_bool().unwrap_or(self.show_brush),
_ => {
let mut v = self.params.options_json();
v[key] = value.clone();
if key == "diameter" {
v["width"] = value.clone();
v["height"] = value.clone();
}
if let Some(p) = LiquifyParams::from_json(&v) {
self.params = p;
}
}
}
}
fn busy(&self) -> bool {
@@ -549,6 +791,27 @@ impl Tool for LiquifyTool {
self.sizing = None;
if std::mem::take(&mut self.active) { vec![Action::Commit] } else { vec![] }
}
/// Held still: every [`HOLD_EVERY`] the last sample repeats (one more dab there).
fn tick(&mut self, cx: &ToolContext, dt: f64) -> Vec<Action> {
if !self.wants_ticks() || !dt.is_finite() || dt <= 0.0 {
return vec![];
}
self.held += dt;
let mut n = 0;
// A hair under the period, so a sum of ticks that is one in exact arithmetic counts.
while self.held >= HOLD_EVERY - 1e-9 && n < MAX_HOLDS_PER_TICK {
self.held -= HOLD_EVERY;
n += 1;
}
self.held = self.held.clamp(0.0, HOLD_EVERY);
let Some(&last) = self.points.last().filter(|_| n > 0) else { return vec![] };
self.points.extend(std::iter::repeat_n(last, n as usize));
let (c, v) = self.command(cx);
vec![Action::Preview(c, v)]
}
fn wants_ticks(&self) -> bool {
self.active && self.params.kind.holds()
}
}
#[cfg(test)]
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,519 @@
//! Define Perspective Grid: the dialog's view of the grid ([`GridDefinition`]: type, units, scale,
//! gridline spacing, viewing angle and distance, horizon height, third vanishing point, gridline
//! colours and opacity), the camera behind it (the [`Station`] point) and the built-in presets.
//!
//! The station point is the viewer: `D` points in front of the picture plane, level with the
//! horizon at the centre of vision `cv`. In two- and three-point perspective the viewing angle θ
//! is the angle between the left plane and the picture plane, so the vanishing points sit at
//! `cv − D·cot θ` and `cv + D·tan θ` (the station point lies on the circle over them). A receding
//! axis foreshortens by the distance from the viewer to its vanishing point, which keeps the grid
//! cells square in the scene whatever the angle.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vectorcraft_doc::{Document, Unit};
use vectorcraft_geom::Rect;
use super::{PerspectiveGrid, Plane};
/// A Letter page: where presets are fitted without a document.
pub const LETTER: Rect = Rect::new(0.0, 0.0, 612.0, 792.0);
/// The viewing angle of grids made before it was stored, and of the presets.
pub const DEFAULT_ANGLE: f64 = 45.0;
/// The largest length (points) a grid takes, as [`PerspectiveGrid::validate`] allows.
const MAX_LEN: f64 = 4.0e6;
/// The built-in presets: (name, kind, horizon height as a fraction of the artboard height from
/// the top). The ground line sits at 78 % of the height in all of them.
pub const BUILTINS: [(&str, u8, f64); 8] = [
("[1P-Normal View]", 1, 0.42),
("[1P-Low View]", 1, 0.66),
("[1P-High View]", 1, 0.12),
("[2P-Normal View]", 2, 0.42),
("[2P-Low View]", 2, 0.66),
("[2P-High View]", 2, 0.12),
("[3P-Normal View]", 3, 0.42),
("[3P-Low View]", 3, 0.66),
];
/// Is `name` a built-in preset's (any case)?
pub fn is_builtin(name: &str) -> bool {
BUILTINS.iter().any(|b| b.0.eq_ignore_ascii_case(name.trim()))
}
/// The first artboard of `doc` (a Letter page without one).
pub fn first_artboard(doc: &Document) -> Rect {
doc.artboards.first().map_or(LETTER, |a| a.rect)
}
// Serde defaults of the grid's definition fields.
pub(super) fn points() -> String {
Unit::Points.key().into()
}
pub(super) fn is_points(u: &String) -> bool {
u == Unit::Points.key()
}
pub(super) fn one_to_one() -> [f64; 2] {
[1.0, 1.0]
}
pub(super) fn is_one_to_one(s: &[f64; 2]) -> bool {
*s == one_to_one()
}
pub(super) fn left_rgb() -> Rgb {
Rgb(Plane::Left.color())
}
pub(super) fn right_rgb() -> Rgb {
Rgb(Plane::Right.color())
}
pub(super) fn ground_rgb() -> Rgb {
Rgb(Plane::Ground.color())
}
pub(super) fn half() -> f64 {
50.0
}
/// A `#rrggbb` colour (gridline colours).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Rgb(pub [u8; 3]);
impl Rgb {
/// `#rrggbb` or `rrggbb`.
pub fn parse(s: &str) -> Option<Self> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let byte = |i: usize| h.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok());
Some(Self([byte(0)?, byte(2)?, byte(4)?]))
}
/// `#rrggbb`.
pub fn hex(self) -> String {
let [r, g, b] = self.0;
format!("#{r:02x}{g:02x}{b:02x}")
}
}
impl TryFrom<String> for Rgb {
type Error = String;
fn try_from(s: String) -> Result<Self, String> {
Self::parse(&s).ok_or_else(|| format!("`{s}` isn't a #rrggbb colour"))
}
}
impl From<Rgb> for String {
fn from(c: Rgb) -> String {
c.hex()
}
}
/// The viewer: the centre of vision `x` on the horizon and the distance to the picture plane.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Station {
pub x: f64,
pub distance: f64,
}
/// Define Perspective Grid's fields: the grid as the dialog shows it. Lengths are real-world
/// lengths in `units`, drawn at `scale` (`[artboard, real world]`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct GridDefinition {
/// The preset's name ("" = custom).
pub name: String,
/// 1, 2 or 3-point perspective.
pub kind: u8,
/// A unit name (`points`, `pixels`, `inches`, `centimeters`…).
pub units: String,
/// `[artboard, real world]`: 1:4 draws 4 real-world units as 1.
pub scale: [f64; 2],
/// Gridline every (real-world units).
pub gridline: f64,
/// Viewing angle (degrees, between 0 and 90; two/three-point).
pub angle: f64,
/// Viewing distance: the station point's distance from the picture plane.
pub distance: f64,
/// Horizon height above the ground level.
pub horizon_height: f64,
/// The third vanishing point (three-point) from the centre of vision: x to the right, y up.
pub third_vp: [f64; 2],
pub left_color: Rgb,
pub right_color: Rgb,
pub ground_color: Rgb,
/// Gridline opacity, 0–100 %.
pub opacity: f64,
}
impl Default for GridDefinition {
/// [2P-Normal View] on a Letter page.
fn default() -> Self {
PerspectiveGrid::normal(2, LETTER).definition()
}
}
impl GridDefinition {
/// The unit the lengths are in.
pub fn unit(&self) -> Unit {
Unit::named(&self.units).unwrap_or_default()
}
/// Artboard points per real-world unit.
pub fn ratio(&self) -> f64 {
self.unit().points() * self.scale[0] / self.scale[1]
}
/// Merge `patch` (any subset of the fields; other keys are ignored) into this definition.
pub fn merged(&self, patch: &Value) -> Result<Self, String> {
let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?;
if let (Some(o), Some(p)) = (v.as_object_mut(), patch.as_object()) {
for (k, val) in p {
if o.contains_key(k) {
o.insert(k.clone(), val.clone());
}
}
}
let mut d: Self = serde_json::from_value(v).map_err(|e| e.to_string())?;
d.units = Unit::named(&d.units).ok_or_else(|| format!("unknown unit `{}`", d.units))?.key().into();
d.validate()?;
Ok(d)
}
pub fn validate(&self) -> Result<(), String> {
if !(1..=3).contains(&self.kind) {
return Err("kind must be 1, 2 or 3".into());
}
if Unit::named(&self.units).is_none() {
return Err(format!("unknown unit `{}`", self.units));
}
if !self.scale.iter().all(|s| s.is_finite() && *s > 0.0 && *s <= 1.0e6) {
return Err("scale must be two positive numbers [artboard, real world]".into());
}
let r = self.ratio();
let lens = [self.gridline, self.distance, self.horizon_height, self.third_vp[0], self.third_vp[1]];
if lens.iter().any(|v| !(v * r).is_finite() || (v * r).abs() > MAX_LEN) {
return Err("grid lengths must be finite".into());
}
if self.gridline * r <= 0.0 || self.distance * r <= 0.0 {
return Err("gridline spacing and viewing distance must be positive".into());
}
if (self.horizon_height * r).abs() < 1e-6 {
return Err("the horizon height can't be 0".into());
}
if !(self.angle > 0.0 && self.angle < 90.0) {
return Err("the viewing angle must be between 0 and 90°".into());
}
if !(0.0..=100.0).contains(&self.opacity) {
return Err("opacity must be 0–100".into());
}
Ok(())
}
/// The same grid (names aside, lengths compared in points)?
pub fn same(&self, o: &Self) -> bool {
let (r, s) = (self.ratio(), o.ratio());
let near = |a: f64, b: f64| (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0);
self.kind == o.kind
&& near(self.gridline * r, o.gridline * s)
&& near(self.distance * r, o.distance * s)
&& near(self.horizon_height * r, o.horizon_height * s)
&& (self.kind != 3 || near(self.third_vp[0] * r, o.third_vp[0] * s) && near(self.third_vp[1] * r, o.third_vp[1] * s))
&& (self.kind == 1 || near(self.angle, o.angle))
&& (self.left_color, self.right_color, self.ground_color) == (o.left_color, o.right_color, o.ground_color)
&& near(self.opacity, o.opacity)
}
}
impl PerspectiveGrid {
/// The built-in preset `name` (any case) fitted to `ab`.
pub fn builtin(name: &str, ab: Rect) -> Option<Self> {
let &(name, kind, horizon) = BUILTINS.iter().find(|b| b.0.eq_ignore_ascii_case(name.trim()))?;
let mut g = Self::preset(kind, ab);
let ground = g.origin[1];
g.horizon = ab.y0 + horizon * ab.height().max(1.0);
g.vp_vertical = [g.vp_vertical[0], g.horizon - 3.0 * (ground - g.horizon)];
g.name = name.into();
g.set_station(g.station(), DEFAULT_ANGLE);
Some(g)
}
/// The normal view preset of `kind` (1–3) fitted to `ab`.
pub fn normal(kind: u8, ab: Rect) -> Self {
let name = BUILTINS.iter().find(|b| b.1 == kind.clamp(1, 3)).map_or("", |b| b.0);
Self::builtin(name, ab).unwrap_or_else(|| Self::preset(kind, ab))
}
/// The viewing angle in degrees.
pub fn viewing_angle(&self) -> f64 {
self.angle.unwrap_or(DEFAULT_ANGLE)
}
/// The station point: one-point grids look straight at their vanishing point from
/// `distance`; two/three-point ones stand where the viewing angle puts them (grids without
/// one: halfway between the vanishing points).
pub fn station(&self) -> Station {
let w = self.vp_right - self.vp_left;
match self.angle {
_ if self.kind == 1 => Station { x: self.vp_left, distance: self.distance },
Some(a) if w > 0.0 => {
let (s, c) = a.to_radians().sin_cos();
Station { x: self.vp_left + w * c * c, distance: w * s * c }
}
_ => Station { x: (self.vp_left + self.vp_right) / 2.0, distance: if w.abs() > 1e-9 { w.abs() / 2.0 } else { self.distance } },
}
}
/// Place the vanishing points for a viewer at `st` looking at `angle` degrees (one-point: the
/// vanishing point at the centre of vision).
pub fn set_station(&mut self, st: Station, angle: f64) {
self.distance = st.distance;
self.angle = Some(angle);
if self.kind == 1 {
self.vp_left = st.x;
} else {
let t = angle.to_radians().tan();
self.vp_left = st.x - st.distance / t;
self.vp_right = st.x + st.distance * t;
}
}
/// 1 / the measuring distance of the left, right and vertical axes: the viewer's distance to
/// their vanishing points (grids without a viewing angle: `distance` for all).
pub(super) fn foreshortening(&self) -> (f64, f64, f64) {
let uniform = 1.0 / self.distance.max(1e-9);
if self.kind == 1 || self.angle.is_none() || self.vp_right <= self.vp_left {
return (uniform, uniform, uniform);
}
let st = self.station();
let to = |x: f64, y: f64| 1.0 / (st.distance.powi(2) + (x - st.x).powi(2) + (y - self.horizon).powi(2)).sqrt().max(1e-9);
(to(self.vp_left, self.horizon), to(self.vp_right, self.horizon), to(self.vp_vertical[0], self.vp_vertical[1]))
}
/// After merging `patch` over `old`: a new viewing angle, viewing distance or type moves the
/// vanishing points around the old station point (unless the patch places them), and
/// `distance` reads the station's.
pub(super) fn reconcile(&mut self, old: &Self, patch: &Value) {
let has = |k: &str| patch.get(k).is_some();
let camera = has("angle") || has("distance") && self.kind != 1;
if !(has("vpLeft") || has("vpRight")) && (camera || self.kind != old.kind) {
let st = old.station();
let st = Station { distance: if has("distance") { self.distance } else { st.distance }, ..st };
self.set_station(st, self.viewing_angle());
}
if self.kind != 1 && self.angle.is_some() && self.vp_right > self.vp_left {
self.distance = self.station().distance;
}
}
/// The definition fields' part of [`PerspectiveGrid::validate`].
pub(super) fn validate_definition(&self) -> Result<(), String> {
if self.angle.is_some_and(|a| !(a > 0.0 && a < 90.0)) {
return Err("the viewing angle must be between 0 and 90°".into());
}
if self.kind != 1 && self.angle.is_some() && self.vp_right <= self.vp_left {
return Err("the right vanishing point must be right of the left one".into());
}
if Unit::named(&self.units).is_none() {
return Err(format!("unknown unit `{}`", self.units));
}
if !self.scale.iter().all(|s| s.is_finite() && *s > 0.0 && *s <= 1.0e6) {
return Err("scale must be two positive numbers [artboard, real world]".into());
}
if !(0.0..=100.0).contains(&self.opacity) {
return Err("opacity must be 0–100".into());
}
if self.extent_right.is_some_and(|e| !(e > 0.0 && e <= MAX_LEN)) {
return Err("extentRight must be positive".into());
}
Ok(())
}
/// The grid as Define Grid shows it.
pub fn definition(&self) -> GridDefinition {
let mut d = GridDefinition {
name: self.name.clone(),
kind: self.kind,
units: self.units.clone(),
scale: self.scale,
gridline: 0.0,
angle: self.viewing_angle(),
distance: 0.0,
horizon_height: 0.0,
third_vp: [0.0; 2],
left_color: self.left_color,
right_color: self.right_color,
ground_color: self.ground_color,
opacity: self.opacity,
};
let r = d.ratio();
let st = self.station();
d.gridline = self.cell / r;
d.distance = st.distance / r;
d.horizon_height = (self.origin[1] - self.horizon) / r;
d.third_vp = [(self.vp_vertical[0] - st.x) / r, (self.horizon - self.vp_vertical[1]) / r];
d
}
/// This grid redefined by `def`: only what differs from [`Self::definition`] changes, so the
/// unchanged fields leave the grid exactly as it was. A new type, viewing angle or distance
/// moves the vanishing points around the station point.
pub fn with_definition(&self, def: &GridDefinition) -> Result<Self, String> {
def.validate()?;
let cur = self.definition();
let r = def.ratio();
let near = |a: f64, b: f64| (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0);
let cr = cur.ratio();
let mut g = self.clone();
g.name = def.name.clone();
g.units = def.unit().key().into();
g.scale = def.scale;
(g.left_color, g.right_color, g.ground_color, g.opacity) = (def.left_color, def.right_color, def.ground_color, def.opacity);
g.kind = def.kind;
if !near(def.gridline * r, cur.gridline * cr) {
g.cell = def.gridline * r;
}
if !near(def.horizon_height * r, cur.horizon_height * cr) {
g.horizon = g.origin[1] - def.horizon_height * r;
}
if def.kind != self.kind || !near(def.distance * r, cur.distance * cr) || def.kind != 1 && !near(def.angle, cur.angle) {
let st = Station { distance: def.distance * r, ..self.station() };
g.set_station(st, def.angle);
}
if !(near(def.third_vp[0] * r, cur.third_vp[0] * cr) && near(def.third_vp[1] * r, cur.third_vp[1] * cr)) || g.horizon != self.horizon {
let x = g.station().x;
g.vp_vertical = [x + def.third_vp[0] * r, g.horizon - def.third_vp[1] * r];
}
g.validate()?;
Ok(g)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vectorcraft_geom::Point;
const AB: Rect = Rect::new(0.0, 0.0, 800.0, 600.0);
#[test]
fn the_station_point_places_the_vanishing_points() {
let mut g = PerspectiveGrid::normal(2, AB);
let st = g.station();
assert!((st.x - g.origin[0]).abs() < 1e-9, "the normal view looks at the corner");
// At 30° the left plane turns toward the picture plane: its vanishing point recedes.
g.set_station(st, 30.0);
assert!((st.x - g.vp_left - st.distance * 3f64.sqrt()).abs() < 1e-9);
assert!((g.vp_right - st.x - st.distance / 3f64.sqrt()).abs() < 1e-9);
let back = g.station();
assert!((back.x - st.x).abs() < 1e-9 && (back.distance - st.distance).abs() < 1e-9);
// The station point sees the vanishing points at a right angle.
let (a, b) = (Point::new(g.vp_left - st.x, st.distance), Point::new(g.vp_right - st.x, st.distance));
assert!((a.x * b.x + a.y * b.y).abs() < 1e-6 * st.distance * st.distance);
}
#[test]
fn receding_axes_keep_true_lengths_at_the_picture_plane() {
// A true camera: at the picture plane a step along a receding axis spans its true width
// (cos θ of it on the left, sin θ on the right), whatever the viewing angle.
for angle in [20.0, 45.0, 70.0] {
let mut g = PerspectiveGrid::normal(2, AB);
g.set_station(g.station(), angle);
g.origin[0] = g.station().x;
for pl in [Plane::Left, Plane::Right] {
let h = g.homography(pl).unwrap();
let (o, e) = (h.apply(Point::new(0.0, 0.0)).unwrap(), h.apply(Point::new(1e-3, 0.0)).unwrap());
let s = angle.to_radians();
let dx = if pl == Plane::Left { s.cos() } else { s.sin() };
assert!(((e.x - o.x).abs() / 1e-3 - dx).abs() < 1e-3, "{angle} {pl:?}");
}
}
}
#[test]
fn definition_round_trips_and_unchanged_fields_change_nothing() {
for kind in [1u8, 2, 3] {
let g = PerspectiveGrid::normal(kind, AB);
let d = g.definition();
assert_eq!(g.with_definition(&d).unwrap(), g);
// In inches at 1:4 the same grid reads differently but is the same.
let inches = GridDefinition { units: "inches".into(), scale: [1.0, 4.0], ..d.clone() };
let r = inches.ratio() / d.ratio();
let inches = GridDefinition {
gridline: d.gridline / r,
distance: d.distance / r,
horizon_height: d.horizon_height / r,
third_vp: d.third_vp.map(|v| v / r),
..inches
};
let g2 = g.with_definition(&inches).unwrap();
assert_eq!((g2.origin, g2.horizon, g2.vp_left, g2.vp_right, g2.cell), (g.origin, g.horizon, g.vp_left, g.vp_right, g.cell));
assert!(g2.definition().same(&d));
}
// A grid from before the viewing angle keeps its foreshortening through an unchanged OK.
let legacy = PerspectiveGrid::preset(2, AB);
let d = legacy.definition();
assert_eq!(legacy.with_definition(&GridDefinition { opacity: 80.0, ..d.clone() }).unwrap().angle, None);
assert!(legacy.with_definition(&GridDefinition { angle: 30.0, ..d }).unwrap().angle.is_some());
}
#[test]
fn definition_fields_map_to_the_grid() {
let g = PerspectiveGrid::normal(3, AB);
let d = GridDefinition {
gridline: 2.0,
angle: 30.0,
distance: 5.0,
horizon_height: 3.0,
third_vp: [1.0, 8.0],
units: "inches".into(),
..g.definition()
};
let n = g.with_definition(&d).unwrap();
let st = n.station();
assert!((n.cell - 144.0).abs() < 1e-9);
assert!((st.distance - 360.0).abs() < 1e-6 && (st.x - g.station().x).abs() < 1e-6, "the viewer stays put");
assert!((n.viewing_angle() - 30.0).abs() < 1e-9);
assert!((n.origin[1] - n.horizon - 216.0).abs() < 1e-9);
assert!((n.vp_vertical[0] - st.x - 72.0).abs() < 1e-6 && (n.horizon - n.vp_vertical[1] - 576.0).abs() < 1e-6);
assert_eq!(n.units, "inches");
// Bad values are refused.
for bad in [
json!({"angle": 90}),
json!({"distance": 0}),
json!({"units": "furlongs"}),
json!({"scale": [1, 0]}),
json!({"opacity": 120}),
json!({"kind": 4}),
] {
assert!(d.merged(&bad).is_err(), "{bad}");
}
assert_eq!(d.merged(&json!({"units": "mm", "leftColor": "#00ff00"})).unwrap().units, "millimeters");
assert!(d.merged(&json!({"leftColor": "green"})).is_err());
}
#[test]
fn set_moves_the_vanishing_points_for_a_new_angle_or_distance() {
let g = PerspectiveGrid::normal(2, AB);
let st = g.station();
let n = g.merged(&json!({"angle": 60})).unwrap();
assert!((n.station().x - st.x).abs() < 1e-6 && (n.station().distance - st.distance).abs() < 1e-6);
let n = g.merged(&json!({"distance": 100})).unwrap();
assert!((n.vp_right - n.vp_left - 200.0).abs() < 1e-6);
// Moving a vanishing point keeps the angle; `distance` follows.
let n = g.merged(&json!({"vpLeft": g.vp_left - 100.0})).unwrap();
assert_eq!(n.angle, Some(45.0));
assert!((n.distance - (n.vp_right - n.vp_left) / 2.0).abs() < 1e-6);
assert!(g.merged(&json!({"vpRight": g.vp_left - 10.0})).is_err());
}
#[test]
fn rgb_parses_hex() {
assert_eq!(Rgb::parse("#3366ff"), Some(Rgb([0x33, 0x66, 0xff])));
assert_eq!(Rgb::parse("ff8C1A").map(Rgb::hex).as_deref(), Some("#ff8c1a"));
for bad in ["", "#12345", "#gg0000", "é12345", "#1234567", "+f+f+f"] {
assert_eq!(Rgb::parse(bad), None, "{bad}");
}
}
}
@@ -0,0 +1,142 @@
//! View → Perspective Grid options on the grid: Snap to Grid (plane coordinates land on
//! gridlines), the gridline colours and opacity, and Show Rulers (a ruler up the vertical line
//! where the planes meet, in Define Grid's real-world units).
use vectorcraft_doc::Unit;
use vectorcraft_geom::{Point, Rect, Vec2};
use super::{PerspectiveGrid, Plane};
use crate::Overlay;
/// Snap to Grid reaches this fraction of a cell from a gridline.
const SNAP: f64 = 0.25;
/// Ruler ticks are this many screen pixels long, and labels at least this far apart.
const TICK_PX: f64 = 6.0;
const LABEL_PX: f64 = 36.0;
impl PerspectiveGrid {
/// The gridline (a multiple of the cell) within a quarter cell of `v`, if any.
fn gridline_near(&self, v: f64) -> Option<f64> {
let line = (v / self.cell).round() * self.cell;
((line - v).abs() <= SNAP * self.cell).then_some(line)
}
/// Plane coordinates `q` on the nearest gridlines within a quarter cell (each axis).
pub fn snap_plane(&self, q: Point) -> Point {
Point::new(self.gridline_near(q.x).unwrap_or(q.x), self.gridline_near(q.y).unwrap_or(q.y))
}
/// A plane-space move `d` of art spanning `b` (plane coordinates) adjusted so the edge nearer
/// a gridline on each axis lands on it, within a quarter cell.
pub fn snap_offset(&self, b: Rect, d: Vec2) -> Vec2 {
let axis = |lo: f64, hi: f64, d: f64| {
let pulls = [lo + d, hi + d].map(|e| self.gridline_near(e).map(|l| l - e));
pulls.into_iter().flatten().min_by(|a, b| a.abs().total_cmp(&b.abs())).map_or(d, |c| d + c)
};
Vec2::new(axis(b.x0, b.x1, d.x), axis(b.y0, b.y1, d.y))
}
/// The map that slides art lying on `plane` by `d` in plane coordinates.
pub fn move_map_by(&self, plane: Plane, d: Vec2) -> Option<impl Fn(Point) -> Option<Point> + use<>> {
let h = self.homography(plane)?;
let hi = h.inverse()?;
Some(move |p: Point| h.apply(hi.apply(p)? + d))
}
/// Lock Station Point: the left (`left`) or right vanishing point dragged to `x` turns the
/// view around the station point, which stays put: the other vanishing point moves so the
/// station still sees them at a right angle. A drag past the centre of vision is ignored.
pub fn swing(&mut self, left: bool, x: f64) {
let st = self.station();
let off = if left { st.x - x } else { x - st.x };
if off <= 1e-6 || st.distance <= 0.0 {
return;
}
let a = off.atan2(st.distance).to_degrees();
self.set_station(st, if left { 90.0 - a } else { a });
}
/// A plane's gridline colour (Define Grid).
pub fn plane_color(&self, plane: Plane) -> [u8; 3] {
match plane {
Plane::Left => self.left_color.0,
Plane::Right => self.right_color.0,
Plane::Ground => self.ground_color.0,
Plane::None => plane.color(),
}
}
/// A plane's gridline colour with the grid's opacity.
pub fn line_color(&self, plane: Plane) -> [u8; 4] {
let [r, g, b] = self.plane_color(plane);
[r, g, b, (self.opacity.clamp(0.0, 100.0) * 2.55).round() as u8]
}
/// Show Rulers: ticks at the gridlines up the vertical line where the planes meet (the true
/// height line), labelled in Define Grid's real-world units. `tol` = document units per
/// screen pixel.
pub fn ruler_overlays(&self, tol: f64) -> Vec<Overlay> {
let Some(h) = self.homography(Plane::Left) else { return vec![] };
let unit = Unit::named(&self.units).unwrap_or_default();
let ratio = unit.points() * self.scale[0] / self.scale[1];
let color = [0x40, 0x40, 0x40];
let mut out = vec![];
let (Some(o), Some(top)) = (h.apply(Point::ZERO), h.apply(Point::new(0.0, self.height))) else { return out };
out.push(Overlay::Line { a: o, b: top, color, dashed: false });
// Labels every k cells, k the least that keeps them LABEL_PX apart at the origin.
let cell_px = self.cell / tol.max(1e-9);
let every = (LABEL_PX / cell_px.max(1e-9)).ceil().clamp(1.0, 1.0e6) as u64;
let tick = TICK_PX * tol;
let count = (self.height / self.cell).floor().min(2000.0) as u64;
for i in 0..=count {
let v = i as f64 * self.cell;
let Some(p) = h.apply(Point::new(0.0, v)) else { continue };
let long = i % every == 0;
out.push(Overlay::Line { a: p, b: p + Vec2::new(if long { -2.0 * tick } else { -tick }, 0.0), color, dashed: false });
if long {
let text = format!("{} {}", unit.number(unit.to_pt(v / ratio)), unit.suffix());
out.push(Overlay::Label { p: p + Vec2::new(-2.0 * tick, 0.0), text, color });
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn grid() -> PerspectiveGrid {
PerspectiveGrid { cell: 20.0, ..PerspectiveGrid::normal(2, Rect::new(0.0, 0.0, 800.0, 600.0)) }
}
#[test]
fn snapping_lands_within_a_quarter_cell_on_gridlines() {
let g = grid();
assert_eq!(g.snap_plane(Point::new(43.0, 53.0)), Point::new(40.0, 53.0));
assert_eq!(g.snap_plane(Point::new(-4.9, 61.0)), Point::new(0.0, 60.0));
// The nearer edge lands on a line: the right one (1 from 60) rather than the left (3 from 40).
let b = Rect::new(10.0, 10.0, 34.0, 20.0);
assert_eq!(g.snap_offset(b, Vec2::new(27.0, 30.0)), Vec2::new(26.0, 30.0));
assert_eq!(g.snap_offset(b, Vec2::new(30.0, 0.0)), Vec2::new(30.0, 0.0), "an edge on a line stays");
assert_eq!(g.snap_offset(b, Vec2::new(18.0, 0.0)), Vec2::new(18.0, 0.0), "nothing within reach");
}
#[test]
fn colours_and_rulers_come_from_the_definition() {
let mut g = grid();
g.opacity = 40.0;
g.right_color = super::super::Rgb([1, 2, 3]);
assert_eq!(g.line_color(Plane::Right), [1, 2, 3, 102]);
g.units = "inches".into();
g.cell = 72.0;
let labels: Vec<String> =
g.ruler_overlays(1.0).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect();
assert_eq!(labels.first().map(String::as_str), Some("0 in"));
assert_eq!(labels.get(1).map(String::as_str), Some("1 in"));
g.scale = [1.0, 4.0];
let second = g.ruler_overlays(1.0).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).nth(1);
assert_eq!(second.as_deref(), Some("4 in"), "real-world lengths at 1:4");
}
}
@@ -0,0 +1,156 @@
//! The Plane Switching Widget: a small cube in a corner of the document window whose faces pick
//! the active plane (left, horizontal, right) and whose dot picks none. It stays put on screen
//! when the view pans or zooms ([`ScreenFrame`]); without a window (headless) it sits in the first
//! artboard's corner. Perspective Grid Options (double-click the tool) hide it or move it to
//! another corner ([`WidgetOptions`], kept with the preferences).
use serde::{Deserialize, Serialize};
use vectorcraft_doc::Document;
use vectorcraft_geom::{Point, Vec2};
use super::{PerspectiveGrid, Plane, WidgetGeom, in_quad};
use crate::{ScreenFrame, ToolContext};
/// The widget's size and its margin from the window's edges, in screen pixels.
const SIZE: f64 = 40.0;
const MARGIN: f64 = 10.0;
/// Where the widget sits.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WidgetCorner {
#[default]
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl WidgetCorner {
pub const ALL: [Self; 4] = [Self::TopLeft, Self::TopRight, Self::BottomLeft, Self::BottomRight];
pub fn id(self) -> &'static str {
match self {
Self::TopLeft => "topLeft",
Self::TopRight => "topRight",
Self::BottomLeft => "bottomLeft",
Self::BottomRight => "bottomRight",
}
}
pub fn label(self) -> &'static str {
match self {
Self::TopLeft => "Top Left",
Self::TopRight => "Top Right",
Self::BottomLeft => "Bottom Left",
Self::BottomRight => "Bottom Right",
}
}
/// By id or label, any case.
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s.trim()) || c.label().eq_ignore_ascii_case(s.trim()))
}
}
/// Perspective Grid Options: whether the Plane Switching Widget shows, and where.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct WidgetOptions {
pub show: bool,
pub position: WidgetCorner,
}
impl Default for WidgetOptions {
fn default() -> Self {
Self { show: true, position: WidgetCorner::TopLeft }
}
}
/// Where the widget is: the window it stays put in (none headless) and its corner.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct WidgetPlace<'a> {
pub screen: Option<&'a ScreenFrame>,
pub corner: WidgetCorner,
}
impl PerspectiveGrid {
/// The widget's geometry (`tol` = document units per screen pixel) at `place`.
pub fn widget_at(&self, doc: &Document, tol: f64, place: WidgetPlace) -> WidgetGeom {
let right = matches!(place.corner, WidgetCorner::TopRight | WidgetCorner::BottomRight);
let bottom = matches!(place.corner, WidgetCorner::BottomLeft | WidgetCorner::BottomRight);
let inset = MARGIN + SIZE / 2.0;
// Headless, the first artboard stands in for the window.
let frame = place.screen.copied().unwrap_or_else(|| {
let ab = super::define::first_artboard(doc);
let tol = tol.max(1e-9);
ScreenFrame {
origin: Point::new(ab.x0, ab.y0),
right: Vec2::new(tol, 0.0),
down: Vec2::new(0.0, tol),
size: (ab.width() / tol, ab.height() / tol),
}
});
let (w, h) = frame.size;
let c = Vec2::new(if right { w - inset } else { inset }, if bottom { h - inset } else { inset });
let p = |dx: f64, dy: f64| frame.at(c.x + dx * SIZE, c.y + dy * SIZE);
let faces = vec![
(Plane::Left, [p(-0.5, -0.25), p(0.0, 0.0), p(0.0, 0.5), p(-0.5, 0.25)]),
(Plane::Right, [p(0.0, 0.0), p(0.5, -0.25), p(0.5, 0.25), p(0.0, 0.5)]),
(Plane::Ground, [p(0.0, -0.5), p(0.5, -0.25), p(0.0, 0.0), p(-0.5, -0.25)]),
];
// The dot sits off the cube's corner toward the window's inside.
let (sx, sy) = (if right { -0.62 } else { 0.62 }, if bottom { -0.62 } else { 0.62 });
(faces, (p(sx, sy), 0.12 * SIZE * tol))
}
/// Which widget control is at `p` (see [`Self::widget_at`]).
pub fn widget_hit_at(&self, doc: &Document, tol: f64, place: WidgetPlace, p: Point) -> Option<Plane> {
let (faces, (c, r)) = self.widget_at(doc, tol, place);
if p.distance(c) <= r * 1.5 {
return Some(Plane::None);
}
faces.into_iter().find(|(_, q)| in_quad(q, p)).map(|(pl, _)| pl)
}
/// Which widget control the tool context's pointer `p` is on (none while the widget is hidden).
pub fn widget_hit_cx(&self, cx: &ToolContext, p: Point) -> Option<Plane> {
let corner = cx.plane_widget?;
self.widget_hit_at(cx.doc, cx.tol(1.0), WidgetPlace { screen: cx.screen.as_ref(), corner }, p)
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_geom::Rect;
fn frame(origin: Point, zoom: f64) -> ScreenFrame {
ScreenFrame { origin, right: Vec2::new(1.0 / zoom, 0.0), down: Vec2::new(0.0, 1.0 / zoom), size: (600.0, 400.0) }
}
#[test]
fn the_widget_stays_put_on_screen() {
let d = Document::new(800.0, 600.0);
let g = PerspectiveGrid::normal(2, Rect::new(0.0, 0.0, 800.0, 600.0));
for corner in WidgetCorner::ALL {
// Panned and zoomed, the widget is at the same screen pixels.
let (a, b) = (frame(Point::new(0.0, 0.0), 1.0), frame(Point::new(250.0, -40.0), 4.0));
let (fa, _) = g.widget_at(&d, 1.0, WidgetPlace { screen: Some(&a), corner });
let (fb, _) = g.widget_at(&d, 0.25, WidgetPlace { screen: Some(&b), corner });
for ((_, qa), (_, qb)) in fa.iter().zip(&fb) {
for (pa, pb) in qa.iter().zip(qb) {
let px = (*pb - b.origin) * 4.0;
assert!((px - pa.to_vec2()).hypot() < 1e-9, "{corner:?}");
}
}
// Inside the window, near the chosen corner.
let x = fa[0].1[0].x;
assert!(if matches!(corner, WidgetCorner::TopRight | WidgetCorner::BottomRight) { x > 500.0 } else { x < 100.0 }, "{corner:?}");
let centre = |q: &[Point; 4]| Point::new(q.iter().map(|p| p.x).sum::<f64>() / 4.0, q.iter().map(|p| p.y).sum::<f64>() / 4.0);
assert_eq!(g.widget_hit_at(&d, 0.25, WidgetPlace { screen: Some(&b), corner }, centre(&fb[1].1)), Some(Plane::Right));
}
// Headless, the top-left one is in the first artboard's corner, as before.
let (faces, _) = g.widget(&d, 1.0);
assert_eq!(faces[0].1[0], Point::new(10.0, 20.0));
assert_eq!(WidgetCorner::parse("Bottom Left"), Some(WidgetCorner::BottomLeft));
assert_eq!(WidgetCorner::parse("middle"), None);
}
}
+644 -126
View File
@@ -1,26 +1,42 @@
//! Puppet Warp tool.
//!
//! With artwork selected the tool places three pins automatically (spread over the mesh). Click
//! on the artwork adds a pin, Alt-click deletes one, Delete/Backspace removes the selected pin,
//! and dragging a pin warps the art: every drag previews
//! `object.puppetWarp {ids, pins, moved}` (pins = current pin positions, moved = the same list
//! with the dragged pin at the pointer), committed on release as one undo step. The mesh follows
//! the current shape, so successive drags compose.
//! With artwork selected the tool shows pins at once: the ones already placed on it, else
//! automatic pins at the centre and the end of each limb ([`default_pins`]). A click on the artwork
//! adds a pin and selects it; Shift-click adds pins to the selection (or takes them out); dragging
//! a pin moves every selected pin and warps the art; Alt-dragging near (not on) a selected pin
//! turns the art around it, and that pin keeps the turn; Delete/Backspace remove the selected pins.
//! A click off the mesh places nothing and says so.
//!
//! The pins live in the document ([`PuppetPins`]) with the rest shape they started from, so every
//! warp starts from that shape (dragging a pin back restores the original, warps don't stack) and
//! Undo/Redo take the pins back with the art. They stay while the same artwork is selected; a new
//! selection starts afresh. Every change runs `object.puppetWarp {rest: true, ids, pins, moved,
//! angles, expand}` (pins = rest positions, moved = where they are now): adding or deleting pins
//! at once, drags as a preview committed on release, each one undo step.
//!
//! Control bar (`tool.setOption`): `showMesh` (on by default), `expand` (Expand Mesh, points, 0
//! allowed) and `selectAllPins`.
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_doc::{Document, Node, NodeId, PuppetPin, PuppetPins};
use vectorcraft_geom::{BezPath, Point, Rect};
use super::arap::{Mesh, MeshOptions, auto_pins};
use super::{BLUE, collect_points};
use super::arap::{self, Mesh, MeshOptions, default_pins};
use super::{BLUE, collect_points, warp_node_with};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// Outlines and bounds of the given objects (what the mesh is built over).
pub fn mesh_input(doc: &Document, ids: &[NodeId]) -> Option<(Vec<BezPath>, Rect)> {
let nodes: Vec<&Node> = ids.iter().map(|id| doc.node(*id)).collect::<Option<_>>()?;
outlines_of(&nodes)
}
fn outlines_of(nodes: &[&Node]) -> Option<(Vec<BezPath>, Rect)> {
let mut outlines = vec![];
let mut pts = vec![];
for id in ids {
let n = doc.node(*id)?;
for n in nodes {
collect_points(n, &mut pts);
n.walk(&mut |c| {
if let Some(p) = c.path_data() {
@@ -39,45 +55,283 @@ pub fn mesh_for(doc: &Document, ids: &[NodeId], expand: f64) -> Option<Mesh> {
Some(Mesh::build(&o, b, MeshOptions { expand, ..Default::default() }))
}
#[derive(Default)]
pub struct PuppetWarpTool {
/// The mesh over `nodes` (a rest shape).
fn mesh_of(nodes: &[Arc<Node>], expand: f64) -> Option<Mesh> {
let refs: Vec<&Node> = nodes.iter().map(|n| n.as_ref()).collect();
let (o, b) = outlines_of(&refs)?;
Some(Mesh::build(&o, b, MeshOptions { expand, ..Default::default() }))
}
/// The solver's view of the pins.
fn solver_pins(pins: &[PuppetPin]) -> Vec<arap::Pin> {
pins.iter().map(|q| arap::Pin { angle: q.angle, ..arap::Pin::new(q.rest, q.at) }).collect()
}
/// Do the pins leave the art as it is (each where it started, turned by nothing)?
fn at_rest(pins: &[PuppetPin]) -> bool {
pins.iter().all(|q| q.rest == q.at && q.angle.is_none_or(|a| a == 0.0))
}
/// A rest shape and the pins placed on it (`None`: none yet).
pub type RestPins = (Vec<Arc<Node>>, Option<Vec<PuppetPin>>);
/// The rest shape of `ids` and the pins placed on it: the document's pins when they belong to
/// these objects as the last warp left them; when something else edited them since, the same pins
/// starting again from the objects as they are; `None` pins: none placed yet.
pub fn rest_and_pins(doc: &Document, ids: &[NodeId]) -> Option<RestPins> {
let current: Vec<Arc<Node>> = ids.iter().map(|id| doc.node_arc(*id).cloned()).collect::<Option<_>>()?;
Some(match doc.puppet.as_deref().filter(|p| p.ids == ids) {
Some(p) if p.holds(doc, ids) => (p.rest.clone(), Some(p.pins.clone())),
Some(p) => (current, Some(p.pins.iter().map(|q| PuppetPin { rest: q.at, at: q.at, angle: q.angle.map(|_| 0.0) }).collect())),
None => (current, None),
})
}
/// The Puppet Warp pins in effect on some objects, with the mesh they warp.
pub struct PinSet {
pub ids: Vec<NodeId>,
pub pins: Vec<PuppetPin>,
/// No pin was placed yet: these are the automatic ones.
pub auto: bool,
pub mesh: Mesh,
/// The mesh vertices as the pins warp them.
pub deformed: Vec<Point>,
/// The pins warp the art (else `deformed` is the rest mesh).
warped: bool,
}
impl PinSet {
/// The pins on `ids` (see [`rest_and_pins`]) with the mesh built with Expand `expand`.
pub fn of(doc: &Document, ids: &[NodeId], expand: f64) -> Option<Self> {
let (rest, pins) = rest_and_pins(doc, ids)?;
let mesh = mesh_of(&rest, expand)?;
let auto = pins.is_none();
let pins = pins.unwrap_or_else(|| default_pins(&mesh).into_iter().map(|p| PuppetPin { rest: p, at: p, angle: None }).collect());
let warped = !at_rest(&pins);
let deformed = if warped { arap::deform(&mesh, &solver_pins(&pins)) } else { mesh.verts.clone() };
Some(Self { ids: ids.to_vec(), pins, auto, mesh, deformed, warped })
}
/// Where on the rest shape the art now at `q` comes from (None: off the mesh).
pub fn rest_of(&self, q: Point) -> Option<Point> {
if self.warped { self.mesh.unmap(&self.deformed, q) } else { self.mesh.contains(q).then_some(q) }
}
/// The turn (radians) the art has around pin `i`: the one it holds, else the warp's there.
pub fn turn_of(&self, i: usize) -> f64 {
self.pins.get(i).map_or(0.0, |q| q.angle.or_else(|| self.mesh.turn_at(&self.deformed, q.rest)).unwrap_or(0.0))
}
/// The `object.puppetWarp` params that set `pins` on these objects.
pub fn params(&self, pins: &[PuppetPin], expand: f64) -> Value {
json!({
"rest": true,
"ids": crate::json_ids(&self.ids),
"pins": pins.iter().map(|q| json!([q.rest.x, q.rest.y])).collect::<Vec<_>>(),
"moved": pins.iter().map(|q| json!([q.at.x, q.at.y])).collect::<Vec<_>>(),
"angles": pins.iter().map(|q| q.angle.map(f64::to_degrees)).collect::<Vec<_>>(),
"expand": expand,
})
}
/// The warped mesh as one path (for the overlay).
fn mesh_path(&self) -> BezPath {
let mut bp = BezPath::new();
for t in &self.mesh.tris {
let [a, b, c] = t.map(|i| self.deformed.get(i).copied().unwrap_or(self.mesh.verts[i]));
bp.move_to(a);
bp.line_to(b);
bp.line_to(c);
bp.close_path();
}
bp
}
}
/// Put `pins` on `ids`: warp their rest shape (see [`rest_and_pins`]) so each pin lands where it
/// is now, and keep the pins in the document. With no pins the art stays as it is and the pins
/// start again from it.
pub fn warp_from_rest(doc: &mut Document, ids: &[NodeId], pins: Vec<PuppetPin>, expand: f64) -> Result<(), String> {
let (rest, _) = rest_and_pins(doc, ids).ok_or("nothing to warp")?;
let rest = if pins.is_empty() {
ids.iter().map(|id| doc.node_arc(*id).cloned()).collect::<Option<Vec<_>>>().ok_or("nothing to warp")?
} else {
let mesh = mesh_of(&rest, expand).ok_or("nothing to warp")?;
if let Some(i) = pins.iter().position(|q| !mesh.contains(q.rest)) {
return Err(format!("pin {i} is off the artwork (outside its mesh)"));
}
let deformed = arap::deform(&mesh, &solver_pins(&pins));
let f = |q: Point| mesh.map(&deformed, q);
for (id, r) in ids.iter().zip(&rest) {
let mut n = Node::clone(r);
warp_node_with(&mut n, &f);
*doc.node_mut(*id).ok_or("the artwork is gone")? = n;
}
rest
};
let result = ids.iter().map(|id| doc.node_arc(*id).cloned()).collect::<Option<Vec<_>>>().ok_or("the artwork is gone")?;
doc.puppet = Some(Arc::new(PuppetPins { ids: ids.to_vec(), rest, result, pins }));
Ok(())
}
/// Within this many pixels of a selected pin (and not on it), Alt-dragging turns the art around it.
const TURN_REACH_PX: f64 = 24.0;
/// Radius (pixels) of the dotted circle shown around the pin the art would turn around.
const TURN_RING_PX: f64 = 16.0;
/// The feedback for a click off the mesh.
const OFF_MESH: &str = "Pins go on the artwork";
const WARN: [u8; 3] = [0xd9, 0x3f, 0x3f];
/// A drag in progress.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Gesture {
/// The selected pins move by `to - from`.
Move { from: Point, to: Point },
/// The art turns around pin `pin`: `base` (radians) is its turn when the drag began, `from`
/// the pointer's angle around it then, `angle` how far the drag has turned it since.
Turn { pin: usize, base: f64, from: f64, angle: f64 },
}
/// What the cached pins were computed from (holding the objects keeps the pointers unique).
struct Key {
ids: Vec<NodeId>,
pins: Vec<Point>,
selected: Option<usize>,
drag: Option<(usize, Point)>,
/// Control bar: Show Mesh.
show_mesh: bool,
/// Control bar: Expand Mesh (points).
nodes: Vec<Arc<Node>>,
puppet: Option<Arc<PuppetPins>>,
expand: f64,
}
impl PuppetWarpTool {
/// Re-seed pins when the selection changed.
fn sync(&mut self, cx: &ToolContext) {
impl Key {
fn of(cx: &ToolContext, expand: f64) -> Option<Self> {
let ids = cx.selection.objects.clone();
if ids == self.ids {
return;
}
self.ids = ids;
self.selected = None;
self.pins = if self.ids.is_empty() {
vec![]
} else {
mesh_for(cx.doc, &self.ids, self.expand_or_default()).map(|m| auto_pins(&m, 3)).unwrap_or_default()
let nodes = ids.iter().map(|id| cx.doc.node_arc(*id).cloned()).collect::<Option<_>>()?;
Some(Self { ids, nodes, puppet: cx.doc.puppet.clone(), expand })
}
fn same(&self, o: &Key) -> bool {
let ptrs = |a: &[Arc<Node>], b: &[Arc<Node>]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y));
let puppet = match (&self.puppet, &o.puppet) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(a, b) => a.is_none() && b.is_none(),
};
self.ids == o.ids && ptrs(&self.nodes, &o.nodes) && puppet && self.expand == o.expand
}
fn expand_or_default(&self) -> f64 {
if self.expand > 0.0 { self.expand } else { 3.0 }
}
struct Cached {
key: Key,
set: PinSet,
mesh: BezPath,
}
pub struct PuppetWarpTool {
/// The objects the selected pins belong to.
ids: Vec<NodeId>,
/// Selected pins (indices into the pins).
selected: Vec<usize>,
drag: Option<Gesture>,
/// The pins as the drag began (each preview starts from them).
start: Option<Arc<Cached>>,
/// The pointer and the modifiers held while hovering (for the turn ring).
hover: Option<(Point, Mods)>,
/// Where a click off the mesh was (its feedback shows until the pointer moves on).
off_mesh: Option<Point>,
/// Control bar: Show Mesh.
show_mesh: bool,
/// Control bar: Expand Mesh (points, 0 allowed).
expand: f64,
/// The pins and mesh of the selection, recomputed when the art, its pins or Expand change
/// (overlays ask every frame).
cache: Mutex<Option<Arc<Cached>>>,
}
impl Default for PuppetWarpTool {
fn default() -> Self {
Self {
ids: vec![],
selected: vec![],
drag: None,
start: None,
hover: None,
off_mesh: None,
show_mesh: true,
expand: 3.0,
cache: Mutex::new(None),
}
}
fn pin_at(&self, cx: &ToolContext, p: Point) -> Option<usize> {
}
impl PuppetWarpTool {
/// The pins on the selection (None: nothing selected).
fn pins(&self, cx: &ToolContext) -> Option<Arc<Cached>> {
let key = Key::of(cx, self.expand).filter(|k| !k.ids.is_empty())?;
let mut cache = self.cache.lock().ok();
if let Some(c) = cache.as_deref().and_then(Option::as_ref).filter(|c| c.key.same(&key)) {
return Some(c.clone());
}
let set = PinSet::of(cx.doc, &key.ids, self.expand)?;
let c = Arc::new(Cached { mesh: set.mesh_path(), key, set });
if let Some(slot) = cache.as_deref_mut() {
*slot = Some(c.clone());
}
Some(c)
}
/// The last pins computed (for `options`, which has no document).
fn last_pins(&self) -> Option<Arc<Cached>> {
self.cache.lock().ok().and_then(|c| c.clone())
}
/// A new selection starts with no pin selected.
fn sync(&mut self, cx: &ToolContext) {
if cx.selection.objects != self.ids {
self.ids = cx.selection.objects.clone();
self.selected.clear();
}
}
fn pin_at(cx: &ToolContext, set: &PinSet, p: Point) -> Option<usize> {
let tol = cx.tol(7.0);
self.pins.iter().enumerate().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))).map(|(i, _)| i)
set.pins.iter().enumerate().map(|(i, q)| (i, q.at.distance(p))).filter(|(_, d)| *d <= tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
}
fn params(&self, i: usize, to: Point) -> Value {
let mut moved = self.pins.clone();
moved[i] = to;
let pj = |v: &[Point]| Value::Array(v.iter().map(|p| json!([p.x, p.y])).collect());
json!({"ids": crate::json_ids(&self.ids), "pins": pj(&self.pins), "moved": pj(&moved), "expand": self.expand_or_default()})
/// The selected pin the art would turn around with Alt held at `p`: near it, not on a pin.
fn turn_pin_at(&self, cx: &ToolContext, set: &PinSet, p: Point) -> Option<usize> {
if Self::pin_at(cx, set, p).is_some() {
return None;
}
let reach = cx.tol(TURN_REACH_PX);
self.selected
.iter()
.filter_map(|&i| Some((i, set.pins.get(i)?.at.distance(p))))
.filter(|(_, d)| *d <= reach)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
}
/// The pins during gesture `g`.
fn targets(&self, set: &PinSet, g: Gesture) -> Vec<PuppetPin> {
let mut pins = set.pins.clone();
for (i, q) in pins.iter_mut().enumerate() {
match g {
Gesture::Move { from, to } if self.selected.contains(&i) => q.at += to - from,
Gesture::Turn { pin, base, angle, .. } if pin == i => q.angle = Some(base + angle),
_ => {}
}
}
pins
}
/// Run `object.puppetWarp` with `pins`, then look at the pins it left.
fn apply(&self, set: &PinSet, pins: &[PuppetPin]) -> Vec<Action> {
vec![Action::Exec("object.puppetWarp".into(), set.params(pins, self.expand)), Action::Notify("pins".into())]
}
/// Select pin `i` (Shift: add it to the selection or take it out). → whether it is selected.
fn pick(&mut self, i: usize, shift: bool) -> bool {
match (shift, self.selected.iter().position(|s| *s == i)) {
(true, Some(k)) => {
self.selected.remove(k);
false
}
(true, None) => {
self.selected.push(i);
true
}
// A press on one of several selected pins drags them all.
(false, Some(_)) => true,
(false, None) => {
self.selected = vec![i];
true
}
}
}
}
@@ -88,100 +342,162 @@ impl Tool for PuppetWarpTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
self.sync(cx);
if self.ids.is_empty() {
// Click an object to select it; pins appear on the next event.
if let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) {
return vec![Action::Exec("select.set".into(), json!({"ids": [h.top_object(cx.isolation).0]}))];
}
return vec![];
}
if let Some(i) = self.pin_at(cx, p) {
if ev.mods.alt {
self.pins.remove(i);
self.selected = None;
return vec![];
}
self.selected = Some(i);
self.drag = Some((i, p));
return vec![Action::Begin("Puppet Warp".into())];
}
let inside = cx.doc.bounds_of(&self.ids, false).is_some_and(|b| b.inflate(cx.tol(4.0), cx.tol(4.0)).contains(p));
if inside && !ev.mods.alt {
self.pins.push(p);
self.selected = Some(self.pins.len() - 1);
PointerKind::Move => {
self.hover = Some((p, ev.mods));
if self.off_mesh.is_some_and(|q| q.distance(p) > cx.tol(6.0)) {
self.off_mesh = None;
}
vec![]
}
PointerKind::Drag => match self.drag {
Some((i, _)) => {
self.drag = Some((i, p));
vec![Action::Preview("object.puppetWarp".into(), self.params(i, p))]
PointerKind::Down => {
self.sync(cx);
self.off_mesh = None;
let Some(c) = self.pins(cx) else {
// Click an object to select it: its pins appear at once.
if let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) {
return vec![
Action::Exec("select.set".into(), json!({"ids": [h.top_object(cx.isolation).0]})),
Action::Notify("pins".into()),
];
}
return vec![];
};
let set = &c.set;
if ev.mods.alt
&& let Some(i) = self.turn_pin_at(cx, set, p)
&& let Some(q) = set.pins.get(i)
{
self.drag = Some(Gesture::Turn { pin: i, base: set.turn_of(i), from: (p - q.at).atan2(), angle: 0.0 });
self.start = Some(c.clone());
return vec![Action::Begin("Puppet Warp".into())];
}
None => vec![],
},
if let Some(i) = Self::pin_at(cx, set, p) {
if !self.pick(i, ev.mods.shift) {
return vec![];
}
self.drag = Some(Gesture::Move { from: p, to: p });
self.start = Some(c.clone());
return vec![Action::Begin("Puppet Warp".into())];
}
match set.rest_of(p) {
Some(rest) => {
let mut pins = set.pins.clone();
pins.push(PuppetPin { rest, at: p, angle: None });
self.pick(pins.len() - 1, ev.mods.shift);
self.apply(set, &pins)
}
None => {
// Off the mesh: nothing to pin there (the feedback says so).
self.off_mesh = Some(p);
if !ev.mods.shift {
self.selected.clear();
}
vec![]
}
}
}
PointerKind::Drag => {
let (Some(mut g), Some(c)) = (self.drag, self.start.clone()) else { return vec![] };
match &mut g {
Gesture::Move { to, .. } => *to = p,
Gesture::Turn { pin, from, angle, .. } => {
let Some(q) = c.set.pins.get(*pin) else { return vec![] };
*angle = (p - q.at).atan2() - *from;
}
}
self.drag = Some(g);
vec![Action::Preview("object.puppetWarp".into(), c.set.params(&self.targets(&c.set, g), self.expand))]
}
PointerKind::Up => match self.drag.take() {
Some((i, _)) => {
self.pins[i] = p;
vec![Action::Commit]
Some(_) => {
self.start = None;
vec![Action::Commit, Action::Notify("pins".into())]
}
None => vec![],
},
_ => vec![],
PointerKind::DoubleClick => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if matches!(key, ToolKey::Delete | ToolKey::Backspace)
&& let Some(i) = self.selected.take()
&& i < self.pins.len()
{
self.pins.remove(i);
/// Delete/Backspace remove the selected pins; with none selected they are the shortcut's
/// (Clear deletes the selected objects).
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Delete | ToolKey::Backspace)
&& self.drag.is_none()
&& cx.selection.objects == self.ids
&& self.pins(cx).is_some_and(|c| self.selected.iter().any(|i| *i < c.set.pins.len()))
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if !self.claims_key(cx, key) {
return vec![];
}
vec![]
let Some(c) = self.pins(cx) else { return vec![] };
let gone = std::mem::take(&mut self.selected);
let pins: Vec<PuppetPin> = c.set.pins.iter().enumerate().filter(|(i, _)| !gone.contains(i)).map(|(_, q)| *q).collect();
self.apply(&c.set, &pins)
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
if self.show_mesh
&& cx.selection.objects == self.ids
&& let Some(m) = mesh_for(cx.doc, &self.ids, self.expand_or_default())
{
let mut bp = BezPath::new();
for t in &m.tris {
bp.move_to(m.verts[t[0]]);
bp.line_to(m.verts[t[1]]);
bp.line_to(m.verts[t[2]]);
bp.close_path();
if let Some(c) = self.pins(cx) {
if self.show_mesh {
out.push(Overlay::Path { path: c.mesh.clone(), color: [0x9a, 0x9a, 0x9a], width: 0.5, dashed: false });
}
out.push(Overlay::Path { path: bp, color: [0x9a, 0x9a, 0x9a], width: 0.5, dashed: false });
}
let r = cx.tol(5.0);
for (i, q) in self.pins.iter().enumerate() {
let q = match self.drag {
Some((d, to)) if d == i => to,
_ => *q,
let r = cx.tol(5.0);
for (i, q) in c.set.pins.iter().enumerate() {
let sel = self.selected.contains(&i) && cx.selection.objects == self.ids;
out.push(Overlay::Path {
path: super::ellipse_path(q.at, r, r, 0.0),
color: BLUE,
width: if sel { 3.0 } else { 1.5 },
dashed: false,
});
out.push(Overlay::Anchor { p: q.at, color: BLUE, filled: sel, size: 3.0 });
}
// The dotted circle of a turn: around the pin being turned, or the one Alt would turn.
let ring = match (self.drag, self.hover) {
(Some(Gesture::Turn { pin, .. }), _) => Some(pin),
(None, Some((h, mods))) if mods.alt => self.turn_pin_at(cx, &c.set, h),
_ => None,
};
let circle = super::ellipse_path(q, r, r, 0.0);
out.push(Overlay::Path { path: circle, color: BLUE, width: if self.selected == Some(i) { 3.0 } else { 1.5 }, dashed: false });
out.push(Overlay::Anchor { p: q, color: BLUE, filled: self.selected == Some(i), size: 3.0 });
if let Some(q) = ring.and_then(|i| c.set.pins.get(i)) {
let rr = cx.tol(TURN_RING_PX);
out.push(Overlay::Path { path: super::ellipse_path(q.at, rr, rr, 0.0), color: BLUE, width: 1.0, dashed: true });
}
}
if let Some(p) = self.off_mesh {
out.push(Overlay::Label { p, text: OFF_MESH.into(), color: WARN });
}
out
}
fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor {
match self.pin_at(cx, p) {
Some(_) if mods.alt => Cursor::PenDelete,
Some(_) => Cursor::Move,
None => Cursor::PenAdd,
let Some(c) = self.pins(cx) else { return Cursor::Arrow };
if mods.alt && self.turn_pin_at(cx, &c.set, p).is_some() {
Cursor::Rotate
} else if Self::pin_at(cx, &c.set, p).is_some() {
Cursor::Move
} else if c.set.rest_of(p).is_some() {
Cursor::PenAdd
} else {
// Off the mesh: no pin goes there.
Cursor::NotAllowed
}
}
fn options(&self) -> Value {
json!({"showMesh": self.show_mesh, "expand": self.expand_or_default(), "pins": self.pins.iter().map(|p| json!([p.x, p.y])).collect::<Vec<_>>()})
let pins = self.last_pins().map(|c| c.set.pins.iter().map(|q| json!([q.at.x, q.at.y])).collect::<Vec<_>>()).unwrap_or_default();
json!({"showMesh": self.show_mesh, "expand": self.expand, "pins": pins, "selected": self.selected})
}
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"showMesh" => self.show_mesh = value.as_bool().unwrap_or(self.show_mesh),
"expand" => self.expand = value.as_f64().unwrap_or(self.expand).clamp(0.0, 1000.0),
"selectAllPins" => self.selected = None,
"expand" => {
if let Some(v) = value.as_f64().filter(|v| v.is_finite()) {
self.expand = v.clamp(0.0, 1000.0);
}
}
// Select All Pins (false: deselect them all).
"selectAllPins" => {
let n = self.last_pins().map_or(0, |c| c.set.pins.len());
self.selected = if value.as_bool() == Some(false) { vec![] } else { (0..n).collect() };
}
_ => {}
}
}
@@ -190,10 +506,18 @@ impl Tool for PuppetWarpTool {
}
fn notify(&mut self, cx: &ToolContext, _what: &str) {
self.sync(cx);
// Pick up the pins the command left (and drop selected pins that are gone).
let n = self.pins(cx).map_or(0, |c| c.set.pins.len());
self.selected.retain(|i| *i < n);
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.ids.clear();
self.pins.clear();
self.selected.clear();
self.hover = None;
self.off_mesh = None;
if let Ok(mut c) = self.cache.lock() {
*c = None;
}
self.start = None;
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
}
}
@@ -204,28 +528,222 @@ mod tests {
use crate::testutil::*;
use vectorcraft_doc::Selection;
fn alt() -> Mods {
Mods { alt: true, ..Default::default() }
}
fn shift() -> Mods {
Mods { shift: true, ..Default::default() }
}
/// The tool on a document, running the `object.puppetWarp` actions it emits the way the
/// engine does (previews on the snapshot taken at Begin).
struct Rig {
doc: Document,
sel: Selection,
base: Option<Document>,
tool: PuppetWarpTool,
/// The params of the last command run.
last: Value,
}
impl Rig {
fn new() -> Self {
let (doc, id) = doc_with_rect();
Self { doc, sel: Selection { objects: vec![id], ..Default::default() }, base: None, tool: PuppetWarpTool::default(), last: Value::Null }
}
fn run(&mut self, p: &Value) {
let pts = |k: &str| -> Vec<Point> {
p[k].as_array().unwrap().iter().map(|q| Point::new(q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()
};
let angles = p["angles"].as_array().unwrap().iter().map(|a| a.as_f64().map(f64::to_radians));
let pins = pts("pins").into_iter().zip(pts("moved")).zip(angles).map(|((rest, at), angle)| PuppetPin { rest, at, angle }).collect();
let ids: Vec<NodeId> = p["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect();
assert_eq!(p["rest"], json!(true));
warp_from_rest(&mut self.doc, &ids, pins, p["expand"].as_f64().unwrap()).unwrap();
self.last = p.clone();
}
fn apply(&mut self, acts: Vec<Action>) -> Vec<Action> {
for a in &acts {
match a {
Action::Begin(_) => self.base = Some(self.doc.clone()),
Action::Preview(_, p) => {
self.doc = self.base.clone().unwrap();
self.run(p);
}
Action::Commit => self.base = None,
Action::Exec(c, p) if c == "object.puppetWarp" => self.run(p),
Action::Notify(w) => {
let p = paint();
self.tool.notify(&cx(&self.doc, &self.sel, &p), w);
}
_ => {}
}
}
acts
}
fn ev(&mut self, kind: PointerKind, x: f64, y: f64, mods: Mods) -> Vec<Action> {
let p = paint();
let acts = self.tool.pointer(&cx(&self.doc, &self.sel, &p), &PointerEvent::new(kind, x, y).with_mods(mods));
self.apply(acts)
}
fn click(&mut self, x: f64, y: f64, mods: Mods) -> Vec<Action> {
let mut a = self.ev(PointerKind::Down, x, y, mods);
a.extend(self.ev(PointerKind::Up, x, y, mods));
a
}
fn drag(&mut self, from: (f64, f64), to: (f64, f64), mods: Mods) -> Vec<Action> {
let mut a = self.ev(PointerKind::Down, from.0, from.1, mods);
a.extend(self.ev(PointerKind::Drag, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, mods));
a.extend(self.ev(PointerKind::Drag, to.0, to.1, mods));
a.extend(self.ev(PointerKind::Up, to.0, to.1, mods));
a
}
fn key(&mut self, k: ToolKey) -> Vec<Action> {
let p = paint();
let acts = self.tool.key(&cx(&self.doc, &self.sel, &p), k, Mods::default());
self.apply(acts)
}
fn claims(&self, k: ToolKey) -> bool {
let p = paint();
self.tool.claims_key(&cx(&self.doc, &self.sel, &p), k)
}
fn overlays(&self) -> Vec<Overlay> {
let p = paint();
self.tool.overlays(&cx(&self.doc, &self.sel, &p))
}
fn cursor(&self, x: f64, y: f64, mods: Mods) -> Cursor {
let p = paint();
self.tool.cursor(&cx(&self.doc, &self.sel, &p), Point::new(x, y), mods)
}
fn pins(&self) -> Vec<PuppetPin> {
self.doc.puppet.as_ref().map(|p| p.pins.clone()).unwrap_or_default()
}
fn bounds(&self) -> Rect {
self.doc.node(self.sel.objects[0]).unwrap().geometric_bounds().unwrap()
}
}
#[test]
fn puppet_tool_autopins_adds_and_drags() {
let (d, id) = doc_with_rect();
let s = Selection { objects: vec![id], ..Default::default() };
let p = paint();
let c = cx(&d, &s, &p);
let mut t = PuppetWarpTool::default();
// Click inside the rect away from auto pins: adds a pin.
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 130.0, 170.0));
assert!(acts.is_empty());
assert_eq!(t.pins.len(), 4, "3 auto pins + 1");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 170.0));
let mut r = Rig::new();
// The automatic pins show as soon as there is art selected.
assert_eq!(r.overlays().iter().filter(|o| matches!(o, Overlay::Anchor { .. })).count(), 3, "3 automatic pins");
// Click inside the rect away from them: adds a pin (one command, the pins kept in the document).
let acts = r.click(130.0, 170.0, Mods::default());
assert!(matches!(&acts[0], Action::Exec(c, _) if c == "object.puppetWarp"), "{acts:?}");
assert_eq!(r.pins().len(), 4, "3 auto pins + 1");
assert_eq!(r.tool.options()["selected"], json!([3]));
// Drag the new pin.
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 130.0, 170.0)), vec![Action::Begin("Puppet Warp".into())]);
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 190.0));
let Action::Preview(cmd, v) = &acts[0] else { panic!() };
assert_eq!(r.ev(PointerKind::Down, 130.0, 170.0, Mods::default()), vec![Action::Begin("Puppet Warp".into())]);
let acts = r.ev(PointerKind::Drag, 120.0, 190.0, Mods::default());
let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!(cmd, "object.puppetWarp");
assert_eq!(v["moved"][3], json!([120.0, 190.0]));
assert_eq!(v["pins"][3], json!([130.0, 170.0]));
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 120.0, 190.0)), vec![Action::Commit]);
// Alt-click deletes it.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 120.0, 190.0).with_mods(Mods { alt: true, ..Default::default() }));
assert_eq!(t.pins.len(), 3);
assert_eq!(r.ev(PointerKind::Up, 120.0, 190.0, Mods::default())[0], Action::Commit);
// Alt-click on a pin doesn't delete it (Alt turns the art around a pin): Delete does.
r.click(120.0, 190.0, alt());
assert_eq!(r.pins().len(), 4);
assert!(r.claims(ToolKey::Delete));
r.key(ToolKey::Delete);
assert_eq!(r.pins().len(), 3);
assert!(!r.claims(ToolKey::Delete), "nothing selected: Delete is Clear's");
}
#[test]
fn shift_click_selects_several_pins_that_move_together() {
let mut r = Rig::new();
// Two new pins, the second Shift-added to the selection.
r.click(110.0, 110.0, Mods::default());
r.click(190.0, 110.0, shift());
let (a, b) = (r.pins().len() - 2, r.pins().len() - 1);
assert_eq!(r.tool.selected, vec![a, b]);
// Dragging one moves both.
r.ev(PointerKind::Down, 110.0, 110.0, Mods::default());
let acts = r.ev(PointerKind::Drag, 110.0, 100.0, Mods::default());
let Action::Preview(_, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!((v["moved"][a].clone(), v["moved"][b].clone()), (json!([110.0, 100.0]), json!([190.0, 100.0])));
assert_eq!(v["moved"][0], v["pins"][0], "unselected pins stay");
r.ev(PointerKind::Up, 110.0, 100.0, Mods::default());
// Shift-click takes a pin out of the selection without dragging.
assert!(r.ev(PointerKind::Down, 190.0, 100.0, shift()).is_empty());
assert_eq!(r.tool.selected, vec![a]);
// Select All Pins, then delete them all: the art stays as it is.
let before = r.bounds();
r.tool.set_option("selectAllPins", &json!(true));
assert_eq!(r.tool.selected, (0..r.pins().len()).collect::<Vec<_>>());
r.key(ToolKey::Backspace);
assert!(r.pins().is_empty());
assert_eq!(r.bounds(), before);
}
#[test]
fn alt_drag_near_a_selected_pin_turns_the_art_around_it() {
let mut r = Rig::new();
r.click(110.0, 150.0, Mods::default());
let i = r.pins().len() - 1;
// Alt near (not on) the selected pin: the turn ring and cursor.
r.ev(PointerKind::Move, 125.0, 150.0, alt());
assert_eq!(r.cursor(125.0, 150.0, alt()), Cursor::Rotate);
assert!(r.overlays().iter().any(|o| matches!(o, Overlay::Path { dashed: true, .. })), "dotted ring");
assert_ne!(r.cursor(125.0, 150.0, Mods::default()), Cursor::Rotate, "only with Alt");
// Drag a quarter turn around it.
assert_eq!(r.ev(PointerKind::Down, 125.0, 150.0, alt()), vec![Action::Begin("Puppet Warp".into())]);
let acts = r.ev(PointerKind::Drag, 110.0, 165.0, alt());
let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!(cmd, "object.puppetWarp");
assert!((v["angles"][i].as_f64().unwrap() - 90.0).abs() < 1e-6, "{v}");
assert_eq!(v["moved"][i], v["pins"][i], "the pin stays put");
assert_eq!(v["angles"][0], Value::Null, "other pins turn freely");
r.ev(PointerKind::Up, 110.0, 165.0, alt());
// The turned pin keeps its turn through the next warp.
let q = r.pins()[0].at;
r.drag((q.x, q.y), (q.x + 5.0, q.y), Mods::default());
assert!((r.last["angles"][i].as_f64().unwrap() - 90.0).abs() < 1e-6, "{}", r.last);
}
#[test]
fn show_mesh_is_on_by_default_and_expand_may_be_zero() {
let mut t = PuppetWarpTool::default();
assert_eq!((t.options()["showMesh"].clone(), t.options()["expand"].clone()), (json!(true), json!(3.0)));
t.set_option("expand", &json!(0));
assert_eq!(t.options()["expand"], json!(0.0));
t.set_option("expand", &json!(f64::NAN));
t.set_option("expand", &json!("x"));
assert_eq!(t.options()["expand"], json!(0.0));
}
#[test]
fn warps_start_from_the_rest_shape() {
let mut r = Rig::new();
let rest = r.doc.node(r.sel.objects[0]).cloned().unwrap();
r.click(190.0, 190.0, Mods::default());
// Two drags out and back: the second starts from where the first left the pin.
r.drag((190.0, 190.0), (230.0, 230.0), Mods::default());
assert!(r.bounds().x1 > 215.0, "{:?}", r.bounds());
r.drag((230.0, 230.0), (190.0, 190.0), Mods::default());
// Back where it started: the original shape, not a warp of a warp.
let back = r.doc.node(r.sel.objects[0]).unwrap();
let (a, b) = (rest.path_data().unwrap(), back.path_data().unwrap());
let worst = a.subpaths[0].anchors.iter().zip(&b.subpaths[0].anchors).map(|(x, y)| x.p.distance(y.p)).fold(0.0, f64::max);
assert!(worst < 0.01, "{worst}");
}
#[test]
fn a_click_off_the_mesh_places_no_pin_and_says_so() {
let mut r = Rig::new();
r.click(130.0, 170.0, Mods::default());
assert_eq!(r.cursor(400.0, 400.0, Mods::default()), Cursor::NotAllowed);
assert!(r.click(400.0, 400.0, Mods::default()).is_empty());
assert_eq!(r.pins().len(), 4);
assert!(r.tool.selected.is_empty(), "the click deselects the pins");
assert!(r.overlays().iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == OFF_MESH)));
r.ev(PointerKind::Move, 450.0, 450.0, Mods::default());
assert!(!r.overlays().iter().any(|o| matches!(o, Overlay::Label { .. })), "gone once the pointer moves on");
// The command refuses a pin off the mesh.
let ids = r.sel.objects.clone();
let off = PuppetPin { rest: Point::new(400.0, 400.0), at: Point::new(400.0, 400.0), angle: None };
assert!(warp_from_rest(&mut r.doc, &ids, vec![off], 3.0).unwrap_err().contains("off the artwork"));
}
}
+172 -30
View File
@@ -12,6 +12,10 @@
//! Gestures preview `stroke.widthPoint.set {id, t, left, right, index?}` (side widths in points),
//! `stroke.widthPoint.copy {id, index, t}` or, for several points, `stroke.widthProfile.set {ids,
//! points}`, and commit on release; Delete runs `stroke.widthPoint.remove {id, indices}`.
//!
//! A compound path's stroke is the compound's: its members are edited through it, and the width
//! points show on every subpath of the stroke (one profile runs along each). The cursor says what a
//! drag does: add a width point on a stroke, or move or widen the one under it.
use serde_json::json;
use vectorcraft_doc::{Document, NodeId, NodeKind, StrokeLayer};
@@ -28,6 +32,9 @@ const SAME_T: f64 = 1e-6;
/// Where the pointer is relative to a stroked path.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Spot {
/// The object whose stroke it is (a compound path for its members).
owner: NodeId,
/// The path under the pointer.
id: NodeId,
sub: usize,
/// Fraction along the subpath.
@@ -93,12 +100,31 @@ struct Drag {
pub struct WidthTool {
hover: Option<Spot>,
drag: Option<Drag>,
/// Selected width points: the path and their positions t.
/// Selected width points: the stroke's owner and their positions t.
selected: Option<(NodeId, Vec<f64>)>,
}
fn stroke_of(doc: &Document, id: NodeId) -> Option<&StrokeLayer> {
doc.node(id)?.appearance.stroke().filter(|s| s.visible && s.width > 0.0 && !s.paint.is_none())
/// The object whose stroke path `id` draws with: its compound path, if it is a member of one.
pub fn stroke_owner(doc: &Document, id: NodeId) -> NodeId {
doc.parent_of(id).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id)
}
/// The visible weighted stroke of `owner`.
fn stroke_of(doc: &Document, owner: NodeId) -> Option<&StrokeLayer> {
doc.node(owner)?.appearance.stroke().filter(|s| s.visible && s.width > 0.0 && !s.paint.is_none())
}
/// The paths a stroke owner draws: itself, or a compound path's members.
fn paths_of(doc: &Document, owner: NodeId) -> Vec<NodeId> {
let mut out = vec![];
if let Some(n) = doc.node(owner) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { guide: false, .. }) {
out.push(c.id);
}
});
}
out
}
/// Side widths (points) of a stroke at fraction `t`.
@@ -116,7 +142,8 @@ impl WidthTool {
}
let n = cx.doc.node(id)?;
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None };
let st = stroke_of(cx.doc, id)?;
let owner = stroke_owner(cx.doc, id);
let st = stroke_of(cx.doc, owner)?;
let widest = st.profile.as_ref().map_or(1.0, |pr| pr.points.iter().map(|q| q.1.max(q.2)).fold(1.0, f64::max));
let reach = widest * st.width / 2.0 + cx.tol(4.0);
if !path.bounds()?.inflate(reach, reach).contains(p) {
@@ -127,10 +154,11 @@ impl WidthTool {
return None;
}
let (left, right) = sides_at(st, t);
Some((d, Spot { id, sub, t, p: q, n: left_normal(tan), left, right }))
Some((d, Spot { owner, id, sub, t, p: q, n: left_normal(tan), left, right }))
};
let best_of = |ids: &mut dyn Iterator<Item = NodeId>| ids.filter_map(consider).min_by(|a, b| a.0.total_cmp(&b.0));
if let Some(b) = best_of(&mut cx.selection.objects.iter().copied()) {
// The selected paths first (a selected compound path: its members).
if let Some(b) = best_of(&mut cx.selection.objects.iter().flat_map(|id| paths_of(cx.doc, *id))) {
return Some(b.1);
}
let mut all = vec![];
@@ -142,9 +170,9 @@ impl WidthTool {
best_of(&mut all.into_iter().rev()).map(|b| b.1)
}
/// Width points of `id` on subpath `sub`.
fn points_of(cx: &ToolContext, id: NodeId, sub: usize) -> Vec<WPoint> {
let Some(st) = stroke_of(cx.doc, id) else { return vec![] };
/// Width points of `owner`'s stroke on subpath `sub` of path `id`.
fn points_of(cx: &ToolContext, owner: NodeId, id: NodeId, sub: usize) -> Vec<WPoint> {
let Some(st) = stroke_of(cx.doc, owner) else { return vec![] };
let Some(pr) = &st.profile else { return vec![] };
let Some(NodeKind::Path { path, .. }) = cx.doc.node(id).map(|n| &n.kind) else { return vec![] };
let Some(sp) = path.subpaths.get(sub) else { return vec![] };
@@ -158,11 +186,20 @@ impl WidthTool {
.collect()
}
/// Width points of `owner`'s stroke on every subpath it draws.
fn all_points(cx: &ToolContext, owner: NodeId) -> Vec<WPoint> {
let subpaths = |id: NodeId| match cx.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Path { path, .. }) => path.subpaths.len(),
_ => 0,
};
paths_of(cx.doc, owner).into_iter().flat_map(|id| (0..subpaths(id)).flat_map(move |sub| Self::points_of(cx, owner, id, sub))).collect()
}
/// The width point part nearest to `p` within a few pixels: a centre (`None`) or the end of a
/// left (`Some(true)`) or right (`Some(false)`) handle.
fn hit(cx: &ToolContext, spot: &Spot, p: Point) -> Option<(WPoint, Option<bool>)> {
let tol = cx.tol(5.0);
Self::points_of(cx, spot.id, spot.sub)
Self::points_of(cx, spot.owner, spot.id, spot.sub)
.into_iter()
.flat_map(|w| [(w, None, w.p), (w, Some(true), w.p + w.n * w.left), (w, Some(false), w.p - w.n * w.right)])
.map(|(w, part, q)| (q.distance(p), w, part))
@@ -217,7 +254,7 @@ impl WidthTool {
/// The command previewing drag `d`.
fn preview(d: &Drag) -> Action {
let id = d.spot.id.0;
let id = d.spot.owner.0;
if let Some(g) = &d.group {
let (dt, dl, dr) = (d.t - d.spot.t, (d.left - d.spot.left) / g.half, (d.right - d.spot.right) / g.half);
let mut pts: Vec<[f64; 3]> = g
@@ -260,8 +297,8 @@ impl Tool for WidthTool {
PointerKind::DoubleClick => {
let Some(spot) = Self::spot_at(cx, p) else { return vec![] };
let Some((w, _)) = Self::hit(cx, &spot, p) else { return vec![] };
self.selected = Some((spot.id, vec![w.t]));
vec![Action::Dialog("widthPoint".into(), json!({"id": spot.id.0, "index": w.index}))]
self.selected = Some((spot.owner, vec![w.t]));
vec![Action::Dialog("widthPoint".into(), json!({"id": spot.owner.0, "index": w.index}))]
}
PointerKind::Down => {
let Some(spot) = Self::spot_at(cx, p) else {
@@ -278,20 +315,20 @@ impl Tool for WidthTool {
Some(left) => Mode::Width(ev.mods.alt.then_some(left)),
};
if ev.mods.shift {
if !self.toggle(spot.id, w.t) {
if !self.toggle(spot.owner, w.t) {
return vec![];
}
} else if mode == Mode::Copy || !self.is_selected(spot.id, w.t) {
self.selected = Some((spot.id, vec![w.t]));
} else if mode == Mode::Copy || !self.is_selected(spot.owner, w.t) {
self.selected = Some((spot.owner, vec![w.t]));
}
let others = Self::points_of(cx, spot.id, spot.sub).into_iter().filter(|o| o.index != w.index).collect();
let group = self.group(cx, spot.id, mode);
let others = Self::points_of(cx, spot.owner, spot.id, spot.sub).into_iter().filter(|o| o.index != w.index).collect();
let group = self.group(cx, spot.owner, mode);
let spot = Spot { t: w.t, p: w.p, n: w.n, left: w.left, right: w.right, ..spot };
Drag { spot, index: Some(w.index), mode, left: w.left, right: w.right, t: w.t, group, others, moved: false }
}
None => {
let side = (p - spot.p).dot(spot.n) >= 0.0;
self.selected = Some((spot.id, vec![spot.t]));
self.selected = Some((spot.owner, vec![spot.t]));
let mode = Mode::Width(ev.mods.alt.then_some(side));
Drag { spot, index: None, mode, left: spot.left, right: spot.right, t: spot.t, group: None, others: vec![], moved: false }
}
@@ -337,11 +374,11 @@ impl Tool for WidthTool {
let dt = d.t - d.spot.t;
self.selected = match (d.group.is_some(), d.moved, self.selected.take()) {
// A click on one of several selected points selects just that one.
(true, false, _) if !ev.mods.shift => Some((d.spot.id, vec![d.spot.t])),
(true, false, _) if !ev.mods.shift => Some((d.spot.owner, vec![d.spot.t])),
// Points moved together stay selected.
(true, _, Some((id, ts))) if d.mode == Mode::Move => Some((id, ts.into_iter().map(|t| (t + dt).clamp(0.0, 1.0)).collect())),
(true, _, sel) => sel,
(false, _, _) => Some((d.spot.id, vec![d.t])),
(false, _, _) => Some((d.spot.owner, vec![d.t])),
};
vec![Action::Commit]
}
@@ -349,6 +386,13 @@ impl Tool for WidthTool {
},
}
}
/// Delete/Backspace remove the selected width points; with none selected they are the
/// shortcut's (Clear deletes the selected objects).
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Delete | ToolKey::Backspace)
&& self.drag.is_none()
&& self.selected.as_ref().is_some_and(|(id, _)| !self.selected_indices(cx, *id).is_empty())
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if !matches!(key, ToolKey::Delete | ToolKey::Backspace) {
return vec![];
@@ -365,9 +409,10 @@ impl Tool for WidthTool {
let mut out = vec![];
let r = cx.tol(4.0);
let focus = self.drag.as_ref().map(|d| d.spot).or(self.hover);
let show = |id: NodeId, sub: usize, out: &mut Vec<Overlay>| {
for w in Self::points_of(cx, id, sub) {
let sel = self.is_selected(id, w.t);
// Every subpath's width points of the stroke under the pointer, and of the selected ones'.
let show = |owner: NodeId, out: &mut Vec<Overlay>| {
for w in Self::all_points(cx, owner) {
let sel = self.is_selected(owner, w.t);
out.push(Overlay::Line { a: w.p + w.n * w.left, b: w.p - w.n * w.right, color: BLUE, dashed: false });
out.push(Overlay::Path { path: diamond(w.p, r), color: BLUE, width: if sel { 2.5 } else { 1.0 }, dashed: false });
out.push(Overlay::Handle { p: w.p + w.n * w.left, color: BLUE });
@@ -375,12 +420,12 @@ impl Tool for WidthTool {
}
};
if let Some(s) = focus {
show(s.id, s.sub, &mut out);
show(s.owner, &mut out);
}
if let Some((id, _)) = &self.selected
&& focus.is_none_or(|s| s.id != *id)
if let Some((owner, _)) = &self.selected
&& focus.is_none_or(|s| s.owner != *owner)
{
show(*id, 0, &mut out);
show(*owner, &mut out);
}
if let Some(d) = &self.drag {
let q = d.spot.p;
@@ -393,8 +438,16 @@ impl Tool for WidthTool {
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
if self.drag.is_some() {
return Cursor::WidthPoint;
}
// The stroke under the pointer, as the last move found it (no search every frame).
match self.hover {
Some(spot) if Self::hit(cx, &spot, p).is_some() => Cursor::WidthPoint,
Some(_) => Cursor::WidthAdd,
None => Cursor::Width,
}
}
fn busy(&self) -> bool {
self.drag.is_some()
@@ -410,6 +463,7 @@ mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::{Appearance, Node, Selection, WidthProfile};
use vectorcraft_geom::Point;
use vectorcraft_geom::{PathData, SubPath};
fn doc_line() -> (Document, NodeId) {
@@ -547,6 +601,94 @@ mod tests {
assert_eq!((cmd, v["t"].as_f64(), v["index"].as_u64()), ("stroke.widthPoint.set", Some(0.5), Some(1)));
}
/// A compound path of two 200 pt lines (y = 200 and 300) with a 10 pt stroke on the compound
/// (its members carry none), a width point at t = 0.5 → (doc, compound, second member).
fn doc_compound() -> (Document, NodeId, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let (c, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let line = |id, y| {
let path = PathData::single(SubPath::polyline(&[Point::new(100.0, y), Point::new(300.0, y)], false));
std::sync::Arc::new(Node::path(id, path, Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0)))
};
let mut comp = Node::new(c, NodeKind::Compound { children: vec![line(a, 200.0), line(b, 300.0)], rule: Default::default() });
comp.appearance = Appearance::default_art();
let st = comp.appearance.stroke_mut().unwrap();
st.width = 10.0;
st.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (1.0, 1.0, 1.0)] });
d.insert(Some(l), 0, comp).unwrap();
(d, c, b)
}
#[test]
fn compound_path_strokes_are_edited_through_the_compound() {
let (d, c, b) = doc_compound();
assert_eq!(stroke_owner(&d, b), c);
let (s, p) = (Selection { objects: vec![c], ..Default::default() }, paint());
let cx = cx(&d, &s, &p);
let mut t = WidthTool::default();
// Drag out from the second member: the compound's stroke gets the width point.
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 301.0, Mods::default())), vec![Action::Begin("Width Point".into())]);
let acts = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 320.0, Mods::default()));
let (cmd, v) = preview(&acts);
assert_eq!((cmd, &v["id"]), ("stroke.widthPoint.set", &json!(c.0)));
t.pointer(&cx, &ev(PointerKind::Up, 150.0, 320.0, Mods::default()));
// The width point in the middle of the first member selects (and deletes) the compound's.
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0, Mods::default()));
t.pointer(&cx, &ev(PointerKind::Up, 200.0, 200.0, Mods::default()));
assert!(t.claims_key(&cx, ToolKey::Delete));
assert_eq!(
t.key(&cx, ToolKey::Delete, Mods::default()),
vec![Action::Exec("stroke.widthPoint.remove".into(), json!({"id": c.0, "indices": [1]}))]
);
}
#[test]
fn width_points_show_on_every_subpath() {
let (d, c, _) = doc_compound();
let (s, p) = (Selection { objects: vec![c], ..Default::default() }, paint());
let cx = cx(&d, &s, &p);
let mut t = WidthTool::default();
let handles = |t: &WidthTool| t.overlays(&cx).iter().filter(|o| matches!(o, Overlay::Handle { .. })).count();
// Hovering one member shows the 3 width points on both (two handle ends each).
t.pointer(&cx, &ev(PointerKind::Move, 150.0, 302.0, Mods::default()));
assert_eq!(handles(&t), 3 * 2 * 2);
// A selected point shows them on every subpath when the pointer is elsewhere.
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 300.0, Mods::default()));
t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0, Mods::default()));
t.pointer(&cx, &ev(PointerKind::Move, 450.0, 450.0, Mods::default()));
assert_eq!(handles(&t), 3 * 2 * 2);
// One path of two subpaths: both.
let (mut d2, id) = doc_line();
let n = d2.node_mut(id).unwrap();
if let NodeKind::Path { path, .. } = &mut n.kind {
path.subpaths.push(SubPath::polyline(&[Point::new(100.0, 260.0), Point::new(300.0, 260.0)], false));
}
n.appearance.stroke_mut().unwrap().profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)] });
let s2 = Selection { objects: vec![id], ..Default::default() };
let cx2 = crate::testutil::cx(&d2, &s2, &p);
let mut t = WidthTool::default();
t.pointer(&cx2, &ev(PointerKind::Down, 200.0, 260.0, Mods::default()));
t.pointer(&cx2, &ev(PointerKind::Up, 200.0, 260.0, Mods::default()));
t.pointer(&cx2, &ev(PointerKind::Move, 450.0, 450.0, Mods::default()));
let ends: Vec<Point> = t.overlays(&cx2).iter().filter_map(|o| if let Overlay::Handle { p, .. } = o { Some(*p) } else { None }).collect();
assert!(ends.iter().any(|q| q.y < 230.0) && ends.iter().any(|q| q.y > 230.0), "both subpaths: {ends:?}");
}
#[test]
fn the_cursor_says_what_a_drag_does() {
let (d, _) = doc_three_points();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = WidthTool::default();
assert_eq!(t.cursor(&c, Point::new(400.0, 400.0), Mods::default()), Cursor::Width);
t.pointer(&c, &ev(PointerKind::Move, 170.0, 201.0, Mods::default()));
assert_eq!(t.cursor(&c, Point::new(170.0, 201.0), Mods::default()), Cursor::WidthAdd, "on the stroke: adds a point");
t.pointer(&c, &ev(PointerKind::Move, 150.0, 200.0, Mods::default()));
assert_eq!(t.cursor(&c, Point::new(150.0, 200.0), Mods::default()), Cursor::WidthPoint, "on a width point");
assert_eq!(t.cursor(&c, Point::new(150.0, 195.0), Mods::default()), Cursor::WidthPoint, "on a handle end");
}
#[test]
fn double_click_on_a_width_point_opens_width_point_edit() {
let (d, id) = doc_three_points();
+65 -1
View File
@@ -18,11 +18,13 @@ pub mod draw2;
pub mod extra;
pub mod guides;
pub mod meshblend;
pub mod meshedit;
pub mod params;
pub mod pen;
pub mod place;
pub mod printtiling;
pub mod select;
pub mod settings;
pub mod shape;
pub mod slice;
pub mod symbolism;
@@ -33,7 +35,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use vectorcraft_color::Paint;
use vectorcraft_doc::{Document, NodeId, Selection, Unit};
use vectorcraft_geom::{BezPath, Point, Rect};
use vectorcraft_geom::{BezPath, Point, Rect, Vec2};
pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info};
@@ -91,6 +93,15 @@ impl PointerEvent {
self.mods = m;
self
}
/// The pen `pressure` of a pointer event given as JSON (control channel, MCP): 0..1, default 1.
pub fn json_pressure(e: &Value) -> f32 {
e.get("pressure").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(1.0, |f| f.clamp(0.0, 1.0) as f32)
}
/// How long the pointer then holds still, in seconds, from a JSON pointer event's `holdMs`
/// (0..60000; default 0): the time [`Tool::tick`] gets.
pub fn json_hold(e: &Value) -> f64 {
e.get("holdMs").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(0.0, |ms| ms.clamp(0.0, 60_000.0) / 1000.0)
}
}
/// Keys tools care about.
@@ -110,6 +121,9 @@ pub enum ToolKey {
Tab,
Home,
End,
/// A digit key 0–9 (5 while dragging with the Perspective Selection tool moves perpendicular
/// to the plane).
Digit(u8),
}
/// What a tool asks the engine to do.
@@ -169,6 +183,26 @@ impl Default for PaintDefaults {
}
}
/// The document window on screen, in document coordinates: widgets that stay put on screen (the
/// Plane Switching Widget) are placed with it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScreenFrame {
/// The window's top-left corner.
pub origin: Point,
/// One screen pixel to the right and one down.
pub right: Vec2,
pub down: Vec2,
/// The window's size in screen pixels.
pub size: (f64, f64),
}
impl ScreenFrame {
/// The document point `x`, `y` screen pixels from the window's top-left corner.
pub fn at(&self, x: f64, y: f64) -> Point {
self.origin + self.right * x + self.down * y
}
}
/// Read-only context a tool sees.
pub struct ToolContext<'a> {
pub doc: &'a Document,
@@ -216,6 +250,10 @@ pub struct ToolContext<'a> {
pub slices_hidden: bool,
/// View → Lock Slices: the Slice Selection tool leaves locked slices alone.
pub slices_locked: bool,
/// The document window (none headless): screen-fixed widgets sit in it.
pub screen: Option<ScreenFrame>,
/// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden.
pub plane_widget: Option<distort::perspective::widget::WidgetCorner>,
}
impl ToolContext<'_> {
@@ -261,6 +299,8 @@ pub enum Overlay {
Highlight { quad: [Point; 4], color: [u8; 4] },
/// A colour chip of fixed screen size (a gradient stop), RGBA; ringed when selected.
Swatch { p: Point, color: [u8; 4], selected: bool },
/// A hairline in a translucent colour (perspective gridlines), RGBA.
GridLine { a: Point, b: Point, color: [u8; 4] },
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -297,6 +337,18 @@ pub enum Cursor {
Slice,
/// The Slice Selection tool: the arrow with a slice badge.
SliceSelect,
/// The Width tool away from strokes.
Width,
/// The Width tool over a stroke: a drag adds a width point.
WidthAdd,
/// The Width tool over a width point or a handle end: a drag moves or widens it.
WidthPoint,
/// The Blend tool away from art: a crosshair with a hollow square.
Blend,
/// The Blend tool over an object it can blend: a crosshair with a filled square.
BlendObject,
/// The Blend tool over an anchor point (the blend starts there): a crosshair with a target.
BlendAnchor,
}
/// A tool state machine.
@@ -343,6 +395,16 @@ pub trait Tool: Send {
fn after_command(&mut self, _cx: &ToolContext) -> Vec<Action> {
vec![]
}
/// Time passes while the pointer button is held (`dt` seconds since the last tick, whether or
/// not the pointer moved): tools that keep working while the brush holds still (Twirl, Pucker,
/// Bloat) act on it. The host supplies the time, so tests and agents drive it exactly.
fn tick(&mut self, _cx: &ToolContext, _dt: f64) -> Vec<Action> {
vec![]
}
/// Does the tool want [`Tool::tick`]s now (the host keeps time only then)?
fn wants_ticks(&self) -> bool {
false
}
}
/// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id.
@@ -430,6 +492,8 @@ pub(crate) mod testutil {
paste_plain_text: false,
slices_hidden: false,
slices_locked: false,
screen: None,
plane_widget: Some(Default::default()),
}
}
}
+160 -50
View File
@@ -1,17 +1,21 @@
//! Blend tool (W) and Mesh tool (U).
//!
//! - Blend: click object A, then object B → `object.blend.make {ids: [A, B]}`. Double-click opens
//! Blend Options. Clicking empty canvas resets.
//! - Blend: click object A, then object B → `object.blend.make {ids: [A, B]}`; clicking an anchor
//! point of a path blends from that point (`starts`). Each further click adds the object as one
//! more key of the new blend (as does clicking a blend first). Double-click or Alt-click opens
//! Blend Options (dialog `blendOptions`). Clicking empty canvas starts over.
//! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through
//! the click); click inside a mesh → `object.mesh.addLine` (new point takes the current fill
//! colour unless Shift); drag a mesh point → `object.mesh.movePoint` previews; Alt-click a mesh
//! point → `object.mesh.deletePoint`.
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
//! new point takes the current fill colour unless Shift); drag a mesh point, or a handle of the
//! point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); Alt-click a mesh point →
//! `object.mesh.deletePoint`.
use serde_json::json;
use vectorcraft_color::Paint;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::Point;
use crate::meshedit::{MeshEdit, grid_of};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
@@ -29,9 +33,48 @@ fn hit_top(cx: &ToolContext, p: Point) -> Option<(NodeId, NodeId)> {
Some((h.top_object(cx.isolation), h.leaf))
}
/// The dialog the Blend tool's double-click, Alt-click and toolbar button open.
const BLEND_OPTIONS: &str = "blendOptions";
/// What the Blend tool picks: an object and the anchor of its first subpath clicked, if any.
type Pick = (NodeId, Option<usize>);
#[derive(Default)]
pub struct BlendTool {
first: Option<NodeId>,
first: Option<Pick>,
/// A blend was just made: the next click adds to it (the selected blend).
chained: bool,
}
/// The anchor of the first subpath of path or compound path `id` (its first member's) nearest
/// `p`, within the click tolerance.
fn anchor_at(cx: &ToolContext, id: NodeId, p: Point) -> Option<usize> {
let n = cx.doc.node(id)?;
let path = match &n.kind {
NodeKind::Path { path, .. } => path,
NodeKind::Compound { children, .. } => children.first()?.path_data()?,
_ => return None,
};
let tol = cx.tol(5.0);
let d = |a: &vectorcraft_geom::Anchor| a.p.distance(p);
let (i, a) = path.subpaths.first()?.anchors.iter().enumerate().min_by(|x, y| d(x.1).total_cmp(&d(y.1)))?;
(d(a) <= tol).then_some(i)
}
/// The object the Blend tool picks at `p` (the top object hit) and the anchor clicked.
fn pick(cx: &ToolContext, p: Point) -> Option<Pick> {
let (top, _) = hit_top(cx, p)?;
Some((top, anchor_at(cx, top, p)))
}
impl BlendTool {
/// The object picked first: the clicked one, or the blend just made.
fn first(&self, cx: &ToolContext) -> Option<Pick> {
self.first.or_else(|| match cx.selection.objects.as_slice() {
[b] if self.chained && cx.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. })) => Some((*b, None)),
_ => None,
})
}
}
impl Tool for BlendTool {
@@ -39,23 +82,29 @@ impl Tool for BlendTool {
"blend"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let options = |t: &mut Self| {
(t.first, t.chained) = (None, false);
vec![Action::Dialog(BLEND_OPTIONS.into(), json!({}))]
};
match ev.kind {
PointerKind::DoubleClick => {
self.first = None;
vec![Action::Dialog("object.blend.options".into(), json!({}))]
}
PointerKind::Down => match (self.first, hit_top(cx, ev.pos)) {
PointerKind::DoubleClick => options(self),
PointerKind::Down if ev.mods.alt => options(self),
PointerKind::Down => match (self.first(cx), pick(cx, ev.pos)) {
(_, None) => {
self.first = None;
(self.first, self.chained) = (None, false);
vec![]
}
(None, Some((top, _))) => {
self.first = Some(top);
(None, Some((top, start))) => {
self.first = Some((top, start));
vec![Action::Exec("select.set".into(), json!({"ids": [top.0]}))]
}
(Some(a), Some((b, _))) if a != b && cx.doc.node(a).is_some() => {
self.first = None;
vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]
(Some((a, sa)), Some((b, sb))) if a != b && cx.doc.node(a).is_some() => {
(self.first, self.chained) = (None, true);
let mut p = json!({"ids": [a.0, b.0]});
if sa.is_some() || sb.is_some() {
p["starts"] = json!([sa, sb]);
}
vec![Action::Exec("object.blend.make".into(), p)]
}
(Some(_), Some(_)) => vec![],
},
@@ -63,34 +112,47 @@ impl Tool for BlendTool {
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some(b) = self.first.and_then(|a| cx.doc.node(a)).and_then(|n| n.geometric_bounds()) else { return vec![] };
vec![Overlay::Anchor { p: b.center(), color: FEEDBACK, filled: true, size: 6.0 }]
let Some((id, start)) = self.first(cx) else { return vec![] };
let Some(n) = cx.doc.node(id) else { return vec![] };
let at = match (start, &n.kind) {
(Some(i), NodeKind::Path { path, .. }) => path.subpaths.first().and_then(|s| s.anchors.get(i)).map(|a| a.p),
(Some(i), NodeKind::Compound { children, .. }) => {
children.first().and_then(|c| c.path_data()).and_then(|p| p.subpaths.first()).and_then(|s| s.anchors.get(i)).map(|a| a.p)
}
_ => None,
};
let Some(p) = at.or_else(|| n.geometric_bounds().map(|b| b.center())) else { return vec![] };
vec![Overlay::Anchor { p, color: FEEDBACK, filled: true, size: 6.0 }]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
match pick(cx, p) {
Some((_, Some(_))) => Cursor::BlendAnchor,
Some(_) => Cursor::BlendObject,
None => Cursor::Blend,
}
}
fn busy(&self) -> bool {
self.first.is_some()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.first = None;
(self.first, self.chained) = (None, false);
vec![]
}
}
#[derive(Default)]
pub struct MeshTool {
drag: Option<(NodeId, usize)>,
edit: MeshEdit,
}
impl MeshTool {
/// Meshes to consider for point hits: selected ones first, then any mesh in the document.
/// Meshes (gradient meshes and mesh envelopes) to consider for point hits: selected ones
/// first, then any in the document.
fn meshes(cx: &ToolContext) -> Vec<NodeId> {
let mut v: Vec<NodeId> =
cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)))).collect();
let mut v: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).and_then(grid_of).is_some()).collect();
let mut rest = vec![];
cx.doc.walk(|n| {
if matches!(n.kind, NodeKind::Mesh(_)) {
if grid_of(n).is_some() {
rest.push(n.id);
}
});
@@ -103,12 +165,8 @@ impl MeshTool {
v
}
fn point_at(cx: &ToolContext, p: Point) -> Option<(NodeId, usize)> {
let tol = cx.tol(5.0);
Self::meshes(cx).into_iter().find_map(|id| match &cx.doc.node(id)?.kind {
NodeKind::Mesh(m) => m.point_near(p, tol).map(|i| (id, i)),
_ => None,
})
fn grab_at(&self, cx: &ToolContext, p: Point) -> Option<crate::meshedit::MeshGrab> {
self.edit.hit(cx, &Self::meshes(cx), p)
}
}
@@ -120,13 +178,14 @@ impl Tool for MeshTool {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
if let Some((id, index)) = Self::point_at(cx, p) {
if ev.mods.alt {
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": id.0, "index": index}))];
if let Some(g) = self.grab_at(cx, p) {
if ev.mods.alt && g.handle.is_none() {
self.edit.unfocus();
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))];
}
self.drag = Some((id, index));
return vec![Action::Begin("Move Mesh Point".into())];
return self.edit.press(g);
}
self.edit.unfocus();
let Some((top, leaf)) = hit_top(cx, p) else { return vec![] };
let color = match (&cx.paint.fill, ev.mods.shift) {
(Paint::Solid { color, .. }, false) => Some(color.to_hex()),
@@ -141,6 +200,9 @@ impl Tool for MeshTool {
}
return vec![Action::Exec("object.mesh.addLine".into(), params)];
}
Some(NodeKind::Envelope { .. }) if cx.doc.node(id).and_then(grid_of).is_some() => {
return vec![Action::Exec("object.mesh.addLine".into(), json!({"id": id.0, "x": p.x, "y": p.y}))];
}
Some(NodeKind::Path { guide: false, .. }) | Some(NodeKind::Compound { .. }) => {
return vec![Action::Exec("object.mesh.create".into(), json!({"ids": [id.0], "at": [p.x, p.y]}))];
}
@@ -149,14 +211,8 @@ impl Tool for MeshTool {
}
vec![]
}
PointerKind::Drag => match self.drag {
Some((id, index)) => vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": id.0, "index": index, "x": p.x, "y": p.y}))],
None => vec![],
},
PointerKind::Up => match self.drag.take() {
Some(_) => vec![Action::Commit],
None => vec![],
},
PointerKind::Drag => self.edit.drag_to(p).unwrap_or_default(),
PointerKind::Up => self.edit.release().unwrap_or_default(),
_ => vec![],
}
}
@@ -170,20 +226,22 @@ impl Tool for MeshTool {
}
}
}
out.extend(self.edit.overlays(cx));
out
}
fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor {
match Self::point_at(cx, p) {
Some(_) if mods.alt => Cursor::PenDelete,
match self.grab_at(cx, p) {
Some(g) if mods.alt && g.handle.is_none() => Cursor::PenDelete,
Some(_) => Cursor::Move,
None => Cursor::PenAdd,
}
}
fn busy(&self) -> bool {
self.drag.is_some()
self.edit.dragging()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
self.edit.unfocus();
self.edit.release().unwrap_or_default()
}
}
@@ -208,6 +266,58 @@ mod tests {
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]);
}
#[test]
fn blend_tool_anchor_clicks_and_options() {
let (mut d, a) = doc_with_rect();
let l = d.layers[0].id;
let b = d.alloc_id();
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = create("blend").unwrap();
// Over an anchor, over an object, away from art.
assert_eq!(t.cursor(&c, Point::new(100.0, 100.0), Mods::default()), Cursor::BlendAnchor);
assert_eq!(t.cursor(&c, Point::new(150.0, 150.0), Mods::default()), Cursor::BlendObject);
assert_eq!(t.cursor(&c, Point::new(250.0, 400.0), Mods::default()), Cursor::Blend);
// The rectangle's first anchor is its top left, b's third its bottom right.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 99.0));
assert!(t.busy());
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 350.0, 150.0));
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0], "starts": [0, 2]}))]);
// Alt-click and double-click open Blend Options.
let alt = Mods { alt: true, ..Default::default() };
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 10.0, 10.0).with_mods(alt)),
vec![Action::Dialog("blendOptions".into(), json!({}))]
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, 10.0, 10.0)), vec![Action::Dialog("blendOptions".into(), json!({}))]);
}
#[test]
fn blend_tool_next_click_adds_to_the_blend_just_made() {
let (mut d, a) = doc_with_rect();
let l = d.layers[0].id;
let g = d.alloc_id();
let key = |id: NodeId, x: f64| {
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0));
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
};
let (k1, k2) = (d.alloc_id(), d.alloc_id());
let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() });
d.insert(Some(l), 1, blend).unwrap();
let mut s = Selection::default();
let p = paint();
let mut t = BlendTool { first: None, chained: true };
s.set([g]);
let c = cx(&d, &s, &p);
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [g.0, a.0]}))]);
// Away from art: start over.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 450.0, 450.0));
assert!(t.first(&c).is_none());
}
#[test]
fn mesh_tool_click_path_creates_mesh() {
let (d, a) = doc_with_rect();
+183
View File
@@ -0,0 +1,183 @@
//! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct
//! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a
//! point focuses it: its handles show and can be dragged too.
use std::borrow::Cow;
use serde_json::json;
use vectorcraft_doc::live::{GradientMesh, envelope_grid};
use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};
use vectorcraft_geom::Point;
use crate::{Action, Overlay, ToolContext};
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
/// The editable grid of `n`: a gradient mesh, or a mesh envelope's grid (not while its contents
/// are being edited).
pub fn grid_of(n: &Node) -> Option<Cow<'_, GradientMesh>> {
match &n.kind {
NodeKind::Mesh(m) => Some(Cow::Borrowed(m)),
NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, editing: false, .. } => {
envelope_grid(*rows, *cols, points, handles).map(Cow::Owned)
}
_ => None,
}
}
/// What a press on a mesh grabs: the mesh, the point and (on the focused point) a handle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MeshGrab {
pub id: NodeId,
pub index: usize,
pub handle: Option<usize>,
}
/// Point and handle drags on meshes.
#[derive(Default)]
pub struct MeshEdit {
/// The point last clicked: its handles show and can be dragged.
focus: Option<(NodeId, usize)>,
drag: Option<MeshGrab>,
}
impl MeshEdit {
/// The handle of the focused point, else the mesh point, under `p` among the meshes `ids`
/// (editable ones, in order).
pub fn hit(&self, cx: &ToolContext, ids: &[NodeId], p: Point) -> Option<MeshGrab> {
let tol = cx.tol(5.0);
if let Some((id, index)) = self.focus
&& ids.contains(&id)
&& let Some(grid) = cx.doc.node(id).and_then(grid_of)
&& let Some(q) = grid.points.get(index)
&& let Some(h) = (0..4).find(|h| q.handles[*h].hypot() > 1e-6 && (q.p + q.handles[*h]).distance(p) <= tol)
{
return Some(MeshGrab { id, index, handle: Some(h) });
}
ids.iter().filter(|id| cx.doc.is_editable(**id)).find_map(|id| {
let index = grid_of(cx.doc.node(*id)?)?.point_near(p, tol)?;
Some(MeshGrab { id: *id, index, handle: None })
})
}
/// Start dragging `grab` (its point takes the focus).
pub fn press(&mut self, grab: MeshGrab) -> Vec<Action> {
self.focus = Some((grab.id, grab.index));
self.drag = Some(grab);
vec![Action::Begin("Move Mesh Point".into())]
}
/// The preview of a drag to `p` (`None` when nothing is being dragged).
pub fn drag_to(&self, p: Point) -> Option<Vec<Action>> {
let g = self.drag?;
let mut params = json!({"id": g.id.0, "index": g.index, "x": p.x, "y": p.y});
if let Some(h) = g.handle {
params["handle"] = json!(h);
}
Some(vec![Action::Preview("object.mesh.movePoint".into(), params)])
}
/// End a drag (`None` when nothing was being dragged).
pub fn release(&mut self) -> Option<Vec<Action>> {
self.drag.take().map(|_| vec![Action::Commit])
}
pub fn dragging(&self) -> bool {
self.drag.is_some()
}
/// Forget the focused point.
pub fn unfocus(&mut self) {
self.focus = None;
}
/// The focused point's handles (lines and ends).
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some((id, index)) = self.focus else { return vec![] };
let Some(grid) = cx.doc.node(id).and_then(grid_of) else { return vec![] };
let Some(q) = grid.points.get(index) else { return vec![] };
let mut out = vec![Overlay::Anchor { p: q.p, color: FEEDBACK, filled: true, size: 5.0 }];
for h in q.handles.iter().filter(|h| h.hypot() > 1e-6) {
out.push(Overlay::Line { a: q.p, b: q.p + *h, color: FEEDBACK, dashed: false });
out.push(Overlay::Handle { p: q.p + *h, color: FEEDBACK });
}
out
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::testutil::*;
use crate::{PointerEvent, PointerKind, Tool};
use vectorcraft_doc::live::{EnvelopeOptions, grid_points};
use vectorcraft_doc::{Document, Selection};
use vectorcraft_geom::{Affine, Rect};
/// The rectangle of [`doc_with_rect`] (100..200 square) in a 1×1 mesh envelope.
fn doc_with_envelope() -> (Document, NodeId) {
let (mut d, a) = doc_with_rect();
let rect = d.node(a).cloned().unwrap();
d.remove(a).unwrap();
let id = d.alloc_id();
let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points: grid_points(Rect::new(100.0, 100.0, 200.0, 200.0), 1, 1), handles: vec![] };
let env = NodeKind::Envelope {
content: vec![Arc::new(rect)],
kind,
fidelity: 50.0,
editing: false,
options: EnvelopeOptions::NEW,
frame: Affine::IDENTITY,
};
let l = d.layers[0].id;
d.insert(Some(l), 0, Node::new(id, env)).unwrap();
(d, id)
}
#[test]
fn the_mesh_tool_drags_envelope_points_and_adds_lines() {
let (d, e) = doc_with_envelope();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = crate::meshblend::create("mesh").unwrap();
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 100.0)), vec![Action::Begin("Move Mesh Point".into())]);
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 210.0, 90.0)),
vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": e.0, "index": 1, "x": 210.0, "y": 90.0}))]
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 210.0, 90.0)), vec![Action::Commit]);
// The clicked point shows its handles (the smooth mesh's) and they can be dragged.
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Handle { .. })));
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 133.33333)), vec![Action::Begin("Move Mesh Point".into())]);
let drag = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 140.0));
assert!(matches!(&drag[..], [Action::Preview(id, p)] if id == "object.mesh.movePoint" && p["handle"] == json!(2)), "{drag:?}");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 220.0, 140.0));
// Inside the envelope: a new row and column.
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 160.0)),
vec![Action::Exec("object.mesh.addLine".into(), json!({"id": e.0, "x": 150.0, "y": 160.0}))]
);
}
#[test]
fn direct_selection_drags_a_selected_envelopes_points() {
let (d, e) = doc_with_envelope();
let mut s = Selection::default();
let p = paint();
let mut t = crate::direct::DirectSelectionTool::new(false);
// Not selected: its points aren't handles to grab.
let c = cx(&d, &s, &p);
assert_ne!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 200.0)), vec![Action::Begin("Move Mesh Point".into())]);
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 200.0));
s.set([e]);
let c = cx(&d, &s, &p);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 200.0)), vec![Action::Begin("Move Mesh Point".into())]);
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 210.0)),
vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": e.0, "index": 2, "x": 90.0, "y": 210.0}))]
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 210.0)), vec![Action::Commit]);
}
}
+58 -1
View File
@@ -3,7 +3,8 @@
//! Click adds a corner anchor; click-drag adds a smooth anchor with symmetric handles (Alt-drag
//! breaks the handles); Shift constrains to 45°. Clicking the first anchor closes the path.
//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues
//! it. The rubber-band preview shows the next segment.
//! it. The rubber-band preview shows the next segment. On a selected blend's spine a click adds a
//! point (on a point no key object sits on: deletes it).
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
@@ -68,6 +69,9 @@ impl Tool for PenTool {
self.drag = Some((p, false));
return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))];
}
if let Some(acts) = spine_click(cx, p, tol) {
return acts;
}
// Continue a selected open path when clicking on one of its ends.
if let Some((_, first, last, _)) = active_path(cx)
&& (p.distance(last) <= tol || p.distance(first) <= tol)
@@ -149,6 +153,13 @@ impl Tool for PenTool {
vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }]
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
if !self.drawing {
match spine_click(cx, p, cx.tol(5.0)).as_deref() {
Some([Action::Exec(c, _)]) if c == "object.blend.spine.removeAnchor" => return Cursor::PenDelete,
Some([_]) => return Cursor::PenAdd,
_ => {}
}
}
if let Some((_, first, last, _)) = active_path(cx) {
if self.drawing && p.distance(first) <= cx.tol(5.0) {
return Cursor::PenClose;
@@ -161,6 +172,25 @@ impl Tool for PenTool {
}
}
/// A click on a selected blend's spine: delete the point under `p` when no key object sits on it
/// (a key's point does nothing), else add one where the spine passes within `tol`.
fn spine_click(cx: &ToolContext, p: Point, tol: f64) -> Option<Vec<Action>> {
for (id, path) in crate::direct::spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)) {
let Some(NodeKind::Blend { children, spec }) = cx.doc.node(id).map(|n| &n.kind) else { continue };
let keys = vectorcraft_doc::live::blend_spine(children, spec).and_then(|(_, a)| a).unwrap_or_default();
if let Some((_, ai, _)) = path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol) {
if keys.contains(&ai) {
return Some(vec![]);
}
return Some(vec![Action::Exec("object.blend.spine.removeAnchor".into(), json!({"id": id.0, "anchor": ai}))]);
}
if path.nearest(p).is_some_and(|n| n.4 <= tol) {
return Some(vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -179,4 +209,31 @@ mod tests {
assert!(matches!(&a[1], Action::Preview(c, _) if c == "path.create"));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Commit]);
}
#[test]
fn clicking_a_selected_blend_spine_adds_a_point() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let key = |id: NodeId, x: f64| {
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0));
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
};
let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
d.insert(
Some(l),
1,
vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }),
)
.unwrap();
let mut s = Selection::default();
s.set([g]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = PenTool::default();
assert_eq!(t.cursor(&cx, Point::new(160.0, 311.0), Mods::default()), Cursor::PenAdd);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0));
assert_eq!(a, vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": g.0, "x": 160.0, "y": 310.0}))]);
// A key's own point is left alone (the click snaps to the spine line).
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 310.0)), vec![]);
}
}
+74
View File
@@ -0,0 +1,74 @@
//! Tool options that persist: a tool keeps them when another tool is chosen and they are saved
//! with the preferences, as the reference app keeps each tool's options. Options that are the
//! state of an interaction (pins, a reference point, the text being edited, the files a place
//! cursor holds) aren't listed: they start afresh.
//!
//! Each row names the tools it covers, the store their values live in and the option keys. A row
//! with a shared store gives every tool it lists one set of values: the Liquify tools share their
//! Global Brush Dimensions, the Symbolism tools their brush. Otherwise each tool has its own store,
//! named after its id.
/// (tools, the store they share (None: each tool's own), option keys).
type Row = (&'static [&'static str], Option<&'static str>, &'static [&'static str]);
/// The Liquify tools.
pub const LIQUIFY: &[&str] = &["warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"];
const SYMBOLISM: &[&str] =
&["symbolSprayer", "symbolShifter", "symbolScruncher", "symbolSizer", "symbolSpinner", "symbolStainer", "symbolScreener", "symbolStyler"];
const ROWS: &[Row] = &[
// Global Brush Dimensions.
(LIQUIFY, Some("liquify"), &["width", "height", "angle", "intensity", "usePressure", "showBrush"]),
(LIQUIFY, None, &["detail", "simplify", "simplifyOn", "rate", "complexity", "horizontal", "vertical", "affectAnchors", "affectIn", "affectOut"]),
(SYMBOLISM, Some("symbolism"), &["diameter", "intensity", "density"]),
(&["mirrorCut"], None, &["axis", "keep"]),
(&["puppetWarp"], None, &["showMesh", "expand"]),
(&["arc"], None, &["closed"]),
(&["spiral"], None, &["decay", "segments", "clockwise"]),
(&["rectangularGrid"], None, &["rows", "columns"]),
(&["polarGrid"], None, &["concentric", "radial"]),
(&["pencil", "paintbrush"], None, &["fidelity", "fill", "editWithin", "closeWithin"]),
(&["smooth"], None, &["fidelity"]),
(&["blobBrush", "eraser"], None, &["size"]),
(&["polygon"], None, &["sides"]),
(&["star"], None, &["points"]),
(&["freeTransform"], None, &["constrain"]),
(&["artboard"], None, &["moveArt"]),
];
/// The stores `tool`'s persistent options live in, each with the option keys it holds.
pub fn stores(tool: &str) -> impl Iterator<Item = (&'static str, &'static [&'static str])> + '_ {
ROWS.iter().filter_map(move |(tools, shared, keys)| {
let id = tools.iter().find(|t| **t == tool)?;
Some((shared.unwrap_or(id), *keys))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_listed_option_is_one_the_tool_reports() {
for (tools, _, keys) in ROWS {
for id in *tools {
assert!(crate::tool_info(id).is_some(), "{id} is a tool");
let opts = crate::create(id).options();
for k in *keys {
assert!(opts.get(*k).is_some(), "{id} reports `{k}` in {opts}");
}
}
}
}
#[test]
fn liquify_tools_share_the_global_brush() {
let warp: Vec<_> = stores("warp").collect();
let twirl: Vec<_> = stores("twirl").collect();
assert_eq!(warp[0], twirl[0]);
assert_eq!(warp[0].0, "liquify");
assert_eq!((warp[1].0, twirl[1].0), ("warp", "twirl"));
assert_eq!(stores("selection").count(), 0);
}
}
+75 -6
View File
@@ -92,6 +92,22 @@ fn temp_tool_id() -> egui::Id {
egui::Id::new("canvas-temp-tool")
}
/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch
/// input, else the last one seen since the press (`pressed`: a new press, whose mouse has none
/// until a pen reports it). A mouse presses fully (1).
fn pen_pressure(ui: &Ui, pressed: bool) -> f32 {
let id = egui::Id::new("canvas-pressure");
let force = ui.input(|i| {
i.events.iter().rev().find_map(|e| match e {
egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)),
_ => None,
})
});
let p = force.or_else(|| if pressed { None } else { ui.data(|d| d.get_temp::<f32>(id)) }).unwrap_or(1.0);
ui.data_mut(|d| d.insert_temp(id, p));
p
}
pub fn mods(m: egui::Modifiers, space: bool) -> Mods {
Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space }
}
@@ -378,6 +394,9 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::RemoveStop => C::NotAllowed,
Cursor::Slice => C::Crosshair,
Cursor::SliceSelect => C::Default,
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
Cursor::WidthPoint => C::Move,
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => C::Crosshair,
}
}
@@ -436,7 +455,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
app.select_tool("selection");
kind = Drag::TempSelect;
}
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) };
dispatch(app, &ev, view);
kind
};
@@ -473,9 +492,16 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
}
Drag::Tool | Drag::TempSelect => {
if pointer.delta() != egui::Vec2::ZERO {
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
}
// Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts
// a quarter second at most.
if app.session.tool_wants_ticks() {
let dt = f64::from(ui.input(|i| i.unstable_dt).min(0.25));
let r = app.session.tool_tick(dt, view);
apply_requests(app, r);
}
}
}
} else {
@@ -499,7 +525,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
}
Drag::Tool | Drag::TempSelect | Drag::Art { .. } => {
if !matches!(d, Drag::Art { .. }) {
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
}
if matches!(d, Drag::TempSelect | Drag::Art { temp: true })
@@ -562,6 +588,7 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Ve
match r {
vectorcraft_engine::UiRequest::Dialog(kind, p) => crate::dialogs::open_tool_dialog(app, &kind, p),
vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t),
vectorcraft_engine::UiRequest::Status(msg) => app.status(msg),
}
}
}
@@ -758,18 +785,36 @@ fn c32(rgb: [u8; 3]) -> Color32 {
/// Outline of a node for highlighting (paths, compound children, text/image bounds).
fn node_outline(n: &Node) -> BezPath {
let mut bp = BezPath::new();
n.walk(&mut |c| match &c.kind {
walk_drawn(n, &mut |c| match &c.kind {
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
if let Some(b) = c.geometric_bounds() {
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
}
}
// An envelope shows its mesh (or top object), not its content.
NodeKind::Envelope { .. } => {
if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) {
bp.extend(lines.to_bezpath());
}
}
_ => {}
});
bp
}
/// [`Node::walk`] over what a selection highlight shows: an envelope's content is left out (the
/// envelope shows its mesh instead).
fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
f(n);
if matches!(n.kind, NodeKind::Envelope { .. }) {
return;
}
for c in n.children().into_iter().flatten() {
walk_drawn(c, f);
}
}
/// The topmost editable object under document point `p` at `zoom` (3 px tolerance).
fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / zoom, outline: app.ui.view.outline, path_only: false };
@@ -1012,8 +1057,14 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let partial = st.selection.partial(*id);
// Path outlines.
stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color));
// Anchors (and handles for selected anchors in direct mode).
n.walk(&mut |c| {
// Anchors (and handles for selected anchors in direct mode); a mesh envelope's points.
walk_drawn(n, &mut |c| {
if let Some((_, Some(grid))) = vectorcraft_doc::live::envelope_overlay(c) {
for q in &grid.points {
anchor_square(p, xf.to_screen(q.p), color, false, if direct { 5.0 } else { 4.0 });
}
return;
}
let NodeKind::Path { path, .. } = &c.kind else { return };
for (si, ai, a) in path.anchors() {
let sel = match partial {
@@ -1054,6 +1105,20 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
p.circle_filled(o, 2.5, color);
}
}
// The spine of each selected blend (or of the blend a selected key object belongs to).
let mut spines = vec![];
for id in &st.selection.objects {
let is_blend = |b: &vectorcraft_doc::NodeId| st.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. }));
let Some(b) = [Some(*id), st.doc.parent_of(*id)].into_iter().flatten().find(is_blend).filter(|b| !spines.contains(b)) else { continue };
spines.push(b);
let Some(NodeKind::Blend { children, spec }) = st.doc.node(b).map(|n| &n.kind) else { continue };
let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec) else { continue };
let color = c32(st.doc.layer_color(b));
stroke_path(p, &path.to_bezpath(), xf, Stroke::new(1.0, color));
for (_, _, a) in path.anchors() {
anchor_square(p, xf.to_screen(a.p), color, false, if direct { 5.0 } else { 4.0 });
}
}
// Live Corners widgets (Selection / Direct Selection on a single live rectangle).
if matches!(tool, "selection" | "directSelection")
&& app.ui.view.corner_widgets
@@ -1127,6 +1192,10 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
p.rect_filled(r, CornerRadius::same(3), t.measure_bg);
p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE);
}
Overlay::GridLine { a, b, color } => {
let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);
p.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, c));
}
Overlay::Swatch { p: pt, color, selected } => {
// A white disc under the colour shows its opacity; a dark rim keeps it readable on
// any art, and the accent ring marks the selected stop.
+68
View File
@@ -138,6 +138,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space(6.0);
crate::place::control_bar_details(app, ui);
crate::toolbar::control_bar_options(app, ui);
crate::dialogs::envelope::control_bar(app, ui);
let shown_stroke = crate::panels::current_stroke(app);
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
@@ -490,6 +491,72 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
],
"gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)],
"artboard" => &[("Click", true), (" to select an artboard | ", false), ("Drag", true), (" on the canvas to create one", false)],
"width" => &[
("Drag", true),
(" a stroke to add a width point | ", false),
("Alt+Drag", true),
(" to change one side | ", false),
("Shift+Click", true),
(" to select more points | ", false),
("Double-click", true),
(" a point to edit it | ", false),
("Delete", true),
(" to remove points", false),
],
"puppetWarp" => &[
("Click", true),
(" the art to add a pin | ", false),
("Shift+Click", true),
(" to select more pins | ", false),
("Drag", true),
(" a pin to warp | ", false),
("Alt+Drag", true),
(" near a selected pin to rotate | ", false),
("Delete", true),
(" to remove pins", false),
],
"warp" => &[
("Drag", true),
(" across paths to push them along | ", false),
("Alt+Drag", true),
(" to size the brush (", false),
("Shift", true),
(" keeps its proportions)", false),
],
"twirl" | "pucker" | "bloat" => &[
("Click or drag", true),
(" over paths, and hold still to keep going | ", false),
("Alt+Drag", true),
(" to size the brush (", false),
("Shift", true),
(" keeps its proportions)", false),
],
"scallop" | "crystallize" | "wrinkle" => &[
("Click or drag", true),
(" over paths to roughen their outlines | ", false),
("Alt+Drag", true),
(" to size the brush (", false),
("Shift", true),
(" keeps its proportions)", false),
],
"perspectiveSelection" => &[
("Drag", true),
(" to move in perspective | ", false),
("Alt+Drag", true),
(" to copy | ", false),
("5", true),
(" while dragging to move perpendicular to the plane | ", false),
("Drag a handle", true),
(" to scale in perspective", false),
],
"blend" => &[
("Click", true),
(" an object, then another to blend them | ", false),
("Click an anchor point", true),
(" to blend from it | ", false),
("Alt+Click", true),
(" to set spacing and orientation", false),
],
_ => return None,
})
}
@@ -551,6 +618,7 @@ mod tests {
assert!(text("zoom").contains(&pretty("Alt+Click")));
assert!(text("rotate").contains(&pretty("Alt+Click")));
assert!(text("eyedropper").contains(&pretty("Alt+Click")));
assert!(text("perspectiveSelection").contains(&pretty("Alt+Drag")) && text("perspectiveSelection").contains("perpendicular"));
assert!(text("paintbrush").contains(&pretty("Cmd+Shift+/")), "the Search Commands shortcut");
if !cfg!(target_os = "macos") {
assert!(text("zoom").contains("Alt+Click") && text("paintbrush").contains("Ctrl+Shift+/"));
+6 -1
View File
@@ -144,7 +144,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
vectorcraft_geom::Point::new(x, y)
};
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: 1.0 }, view);
crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view);
let hold = PointerEvent::json_hold(e);
if hold > 0.0 {
let r = app.session.tool_tick(hold, view);
crate::canvas::apply_requests(app, r);
}
}
ctx.request_repaint();
wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id()})))
+46
View File
@@ -133,6 +133,33 @@ fn slice(p: &Painter, o: Pos2) {
line(p, b + vec2(8.0, 6.0), b + vec2(12.0, 12.0));
}
/// The Width tool: the hollow arrow with a stroke that swells in the middle (a width point),
/// plus a badge: `+` over a stroke (a drag adds a point), a bar across the swell over a width point
/// (a drag moves or widens it).
fn width(p: &Painter, o: Pos2, badge: &str) {
arrow(p, o, true);
let b = o + vec2(11.0, 18.0);
let top: Vec<Pos2> = (0..=8)
.map(|i| {
let t = i as f32 / 8.0;
b + vec2(12.0 * t, -3.5 * (std::f32::consts::PI * t).sin())
})
.collect();
let mut lens = top.clone();
lens.extend(top.iter().rev().map(|q| pos2(q.x, 2.0 * b.y - q.y)));
p.add(Shape::closed_line(lens.clone(), Stroke::new(3.0, HALO)));
p.add(Shape::closed_line(lens, Stroke::new(1.2, INK)));
match badge {
"+" => {
let c = b + vec2(16.0, -6.0);
line(p, c - vec2(3.0, 0.0), c + vec2(3.0, 0.0));
line(p, c - vec2(0.0, 3.0), c + vec2(0.0, 3.0));
}
"point" => line(p, b + vec2(6.0, -6.0), b + vec2(6.0, 6.0)),
_ => {}
}
}
fn ibeam(p: &Painter, o: Pos2) {
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
@@ -140,6 +167,21 @@ fn ibeam(p: &Painter, o: Pos2) {
line(p, o + vec2(-2.0, 3.0), o + vec2(2.0, 3.0));
}
/// The Blend tool: a crosshair with a square below right of the hotspot, hollow away from art,
/// filled over an object; over an anchor point a ringed dot (the blend starts there).
fn blend(p: &Painter, o: Pos2, badge: Cursor) {
crosshair(p, o);
let b = o + vec2(9.0, 9.0);
if badge == Cursor::BlendAnchor {
p.circle_stroke(b + vec2(3.5, 3.5), 3.5, Stroke::new(3.0, HALO));
p.circle_stroke(b + vec2(3.5, 3.5), 3.5, Stroke::new(1.2, INK));
p.circle_filled(b + vec2(3.5, 3.5), 1.4, INK);
return;
}
let fill = if badge == Cursor::BlendObject { INK } else { HALO };
poly(p, vec![b, b + vec2(7.0, 0.0), b + vec2(7.0, 7.0), b + vec2(0.0, 7.0)], fill, INK);
}
/// Paint cursor `c` at `p` on the given (foreground) painter. Returns false for cursors that should
/// stay system cursors (hand, zoom, busy states).
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
@@ -170,6 +212,10 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
arrow(painter, p, false);
slice_badge(painter, p + vec2(11.0, 14.0));
}
Cursor::Width => width(painter, p, ""),
Cursor::WidthAdd => width(painter, p, "+"),
Cursor::WidthPoint => width(painter, p, "point"),
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c),
_ => return false,
}
let _ = pos2;
+213
View File
@@ -0,0 +1,213 @@
//! Object → Blend → Blend Options (also the Blend tool's double-click, Alt-click and toolbar
//! button): Spacing (Smooth Color, Specified Steps or Specified Distance) with its value and
//! Orientation (Align to Page or Align to Path), opened on the selected blend's options and
//! previewed live on it; OK keeps the preview as one undo step (`object.blend.options`). With no
//! blend selected it sets the options new blends start with.
//!
//! Fields: `spacing` (`smooth` | `steps` | `distance`), `steps` (1..1000), `distance` (pt),
//! `orientation` (`page` | `path`), `preview`, and `__target` (`blend` or `defaults`, from
//! `object.blend.info`).
use egui::{CornerRadius, Sense, Stroke, Vec2, pos2, vec2};
use serde_json::{Value, json};
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Blend Options.
pub const KIND: &str = "blendOptions";
const CMD: &str = "object.blend.options";
/// Width of the label column.
const LABEL_W: f32 = 82.0;
/// Spacing modes: (value, label).
const SPACING: [(&str, &str); 3] = [("smooth", "Smooth Color"), ("steps", "Specified Steps"), ("distance", "Specified Distance")];
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Blend Options".into(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM };
/// Open Blend Options on the selected blend's options (else those new blends start with).
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let mut fields = app.session.execute("object.blend.info", &json!({})).map_err(|e| e.to_string())?;
let target = fields["target"].clone();
let Some(o) = fields.as_object_mut() else { return Err("no blend options".into()) };
o.retain(|k, _| matches!(k.as_str(), "spacing" | "steps" | "distance" | "orientation"));
o.insert("__target".into(), target);
o.insert("preview".into(), json!(true));
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
/// Does the dialog edit a blend (rather than the options new blends start with)?
fn on_blend(d: &Dialog) -> bool {
d.str("__target") == "blend"
}
/// `object.blend.options` parameters from the fields.
fn params(d: &Dialog) -> Value {
let mut p = json!({"spacing": d.str("spacing"), "orientation": d.str("orientation")});
match d.str("spacing").as_str() {
"steps" => p["steps"] = json!(d.f64("steps", 5.0).round().clamp(1.0, 1000.0)),
"distance" => p["distance"] = json!(d.f64("distance", 10.0)),
_ => {}
}
p
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
let mode = d.str("spacing");
let shown = SPACING.iter().find(|(v, _)| *v == mode).map_or(SPACING[0].1, |(_, l)| l);
let labels = SPACING.map(|(_, l)| l);
widgets::label_row(ui, "Spacing:", LABEL_W, |ui| {
if let Some((value, _)) = widgets::dropdown(ui, "blend-spacing", shown, &labels, 150.0).and_then(|i| SPACING.get(i)) {
d.fields.insert("spacing".into(), json!(value));
}
ui.add_space(6.0);
match mode.as_str() {
"distance" => {
form::length(ui, d, "distance", unit, 72.0);
}
// Smooth Color picks the count itself: the field shows the steps, disabled.
m => {
let steps = d.f64("steps", 5.0);
ui.add_enabled_ui(m == "steps", |ui| {
if let Some(n) = widgets::plain_field(ui, "blend-steps", steps, "", 0, 72.0) {
d.fields.insert("steps".into(), json!(n.round().clamp(1.0, 1000.0)));
}
});
}
}
});
ui.add_space(6.0);
widgets::label_row(ui, "Orientation:", LABEL_W, |ui| {
let path = d.str("orientation") == "path";
for (value, along, tip) in [("page", false, "Align to Page"), ("path", true, "Align to Path")] {
if orientation_button(ui, along, path == along, tip) {
d.fields.insert("orientation".into(), json!(value));
}
}
});
if on_blend(d) {
let p = params(d);
form::preview(app, ui, d, "Blend Options", CMD, p);
}
false
}
/// An orientation toggle drawn in code: three bars on a curve, upright (Align to Page) or square
/// to the curve (Align to Path), in a pressed well when `selected`. Returns clicked.
fn orientation_button(ui: &mut egui::Ui, along: bool, selected: bool, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(28.0), Sense::click());
let bg = if selected {
t.tool_active
} else if resp.hovered() {
t.hover
} else {
egui::Color32::TRANSPARENT
};
ui.painter().rect_filled(rect, CornerRadius::same(3), bg);
let color = if selected { t.text } else { t.icon };
// An arch through the button: y = c + a·x² with x in -1..1.
let c = rect.center() + vec2(0.0, -2.0);
let at = |x: f32| pos2(c.x + 9.0 * x, c.y + 6.0 * x * x);
let curve: Vec<egui::Pos2> = (0..=12).map(|i| at(-1.0 + i as f32 / 6.0)).collect();
ui.painter().add(egui::Shape::line(curve, Stroke::new(1.0, color)));
for x in [-0.8f32, 0.0, 0.8] {
let p = at(x);
// Normal to the arch at x (pointing up), or straight up.
let n = if along { vec2(-12.0 * x / 9.0, -1.0).normalized() } else { vec2(0.0, -1.0) };
ui.painter().line_segment([p, p + n * 7.0], Stroke::new(2.0, color));
}
resp.on_hover_text(tip).clicked()
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if on_blend(d) { form::commit_preview(app, CMD, params(d)) } else { run_and_close(app, CMD, params(d)) }
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::NodeKind;
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing};
use vectorcraft_engine::Session;
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
fn spec(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::BlendSpec {
match &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
NodeKind::Blend { spec, .. } => spec.clone(),
_ => panic!("not a blend"),
}
}
#[test]
fn opens_on_the_blend_previews_and_keeps_smooth_color() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
app.run("shape.rectangle", json!({"x": 200, "y": 0, "width": 20, "height": 20})).unwrap();
app.run("select.all", json!({})).unwrap();
let id = app.run("object.blend.make", json!({"smooth": true})).unwrap()["id"].as_u64().unwrap();
// The menu item opens the dialog on the blend's own options.
crate::menus::invoke(&mut app, CMD, json!({}));
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND));
frame(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().str("spacing"), "smooth");
// OK without changes keeps Smooth Color (it used to become 5 steps).
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(spec(&app, id).spacing, BlendSpacing::SmoothColor);
// Specified Steps and Align to Path preview on the blend; Cancel rolls them back.
app.run("ui.blendOptions", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("spacing".into(), json!("steps"));
d.fields.insert("steps".into(), json!(3));
d.fields.insert("orientation".into(), json!("path"));
frame(&mut app);
assert_eq!(spec(&app, id).spacing, BlendSpacing::Steps(3), "previewed");
crate::dialogs::cancel(&mut app);
assert_eq!(spec(&app, id).spacing, BlendSpacing::SmoothColor);
// OK keeps them as one undo step.
app.run("ui.blendOptions", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("spacing".into(), json!("distance"));
d.fields.insert("distance".into(), json!(25));
frame(&mut app);
let depth = app.session.doc().unwrap().history.undo.len();
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(spec(&app, id).spacing, BlendSpacing::Distance(25.0));
assert_eq!(app.session.doc().unwrap().history.undo.len(), depth + 1);
}
#[test]
fn with_nothing_selected_it_sets_what_new_blends_start_with() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("ui.blendOptions", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("__target"), "defaults");
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("spacing".into(), json!("steps"));
d.fields.insert("steps".into(), json!(7));
d.fields.insert("orientation".into(), json!("path"));
frame(&mut app);
crate::dialogs::confirm(&mut app).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
app.run("shape.rectangle", json!({"x": 200, "y": 0, "width": 20, "height": 20})).unwrap();
app.run("select.all", json!({})).unwrap();
let id = app.run("object.blend.make", json!({})).unwrap()["id"].as_u64().unwrap();
let s = spec(&app, id);
assert_eq!((s.spacing, s.orientation), (BlendSpacing::Steps(7), BlendOrientation::AlignToPath));
}
}
+7
View File
@@ -36,5 +36,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let cmd = d.str("__command");
let params = form::params(d);
app.ui.dialog = None;
// A tool's click-to-size shape (Flare) goes on the active perspective plane while the grid shows.
let at = |x: &str, y: &str| Some(vectorcraft_geom::Point::new(params.get(x)?.as_f64()?, params.get(y)?.as_f64()?));
if let Some((c, p)) =
at("cx", "cy").or_else(|| at("x", "y")).and_then(|pt| vectorcraft_engine::perspective_click(&app.session, &cmd, &params, pt))
{
return app.run(&c, p);
}
app.run(&cmd, params)
}
+491
View File
@@ -0,0 +1,491 @@
//! Object → Envelope Distort's dialogs, previewed live on the canvas; OK keeps the preview as one
//! undo step:
//!
//! - **Warp Options** (`envelopeWarp`): Make with Warp, or Reset with Warp for a selected envelope
//! (`reset`). Fields `style` (one of the 15 warp styles), `horizontal` (false: Vertical),
//! `bend`, `h` and `v` (%, -100..100).
//! - **Envelope Mesh** (`envelopeMesh`): Make with Mesh, or Reset with Mesh (`reset`, with
//! `maintainShape`). Fields `rows`, `cols` (1..50).
//! - **Envelope Options** (`envelopeOptions`): the selected envelopes' options, or with none
//! selected the options new envelopes get (no preview then). Fields `antiAlias`,
//! `preserveShape` (`clippingMask` | `transparency`), `fidelity` (0..100),
//! `distortAppearance`, `distortLinearGradients`, `distortPatternFills`.
//!
//! The Control bar's envelope controls ([`control_bar`]) live here too.
//!
//! Every dialog also has `preview`. They open with the selected envelope's values
//! (`object.envelope.info`); the menu items and shortcuts of Make with Warp and Make with Mesh
//! open the Reset variant while an envelope is selected, as the reference app's do.
use serde_json::{Value, json};
use vectorcraft_doc::{EnvelopeKind, NodeKind};
use vectorcraft_render::effects::WARP_STYLES;
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kinds.
pub const WARP: &str = "envelopeWarp";
pub const MESH: &str = "envelopeMesh";
pub const OPTIONS: &str = "envelopeOptions";
const MAKE_WARP: &str = "object.envelope.makeWithWarp";
const RESET_WARP: &str = "object.envelope.resetWithWarp";
const MAKE_MESH: &str = "object.envelope.makeWithMesh";
const RESET_MESH: &str = "object.envelope.resetWithMesh";
const OPTIONS_CMD: &str = "object.envelope.options";
/// Width of the label column.
const LABEL_W: f32 = 84.0;
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
fn heading(d: &Dialog) -> String {
match d.kind.as_str() {
WARP => "Warp Options",
MESH if d.bool("reset") => "Reset Envelope Mesh",
MESH => "Envelope Mesh",
_ => "Envelope Options",
}
.into()
}
/// Does the menu item of command `id` open one of these dialogs?
pub fn opens(id: &str) -> bool {
matches!(id, MAKE_WARP | RESET_WARP | MAKE_MESH | RESET_MESH | OPTIONS_CMD)
}
/// Open the dialog command `id`'s menu item opens, with the selected envelope's values.
pub fn open(app: &mut VectorcraftApp, id: &str) -> Result<Value, String> {
let info = app.session.execute("object.envelope.info", &json!({})).map_err(|e| e.to_string())?;
let envelope = !info["id"].is_null();
if matches!(id, RESET_WARP | RESET_MESH) && !envelope {
return Err("select an envelope".into());
}
let num = |k: &str, d: f64| info[k].as_f64().unwrap_or(d);
let (kind, fields) = match id {
MAKE_WARP | RESET_WARP => {
let warp = info["type"] == "warp";
let own = |k: &str, d: f64| if warp { num(k, d) } else { d };
let style = if warp { info["style"].clone() } else { json!("arc") };
let horizontal = !warp || info["horizontal"] != false;
(
WARP,
json!({"reset": envelope, "style": style, "horizontal": horizontal, "bend": own("bend", 50.0), "h": own("h", 0.0), "v": own("v", 0.0), "preview": true}),
)
}
MAKE_MESH | RESET_MESH => {
let mesh = info["type"] == "mesh";
let own = |k: &str| if mesh { num(k, 4.0) } else { 4.0 };
(MESH, json!({"reset": envelope, "rows": own("rows"), "cols": own("cols"), "maintainShape": true, "preview": true}))
}
OPTIONS_CMD => {
let mut f = json!({"__envelope": envelope, "preview": envelope});
for k in ["antiAlias", "preserveShape", "fidelity", "distortAppearance", "distortLinearGradients", "distortPatternFills"] {
f[k] = info[k].clone();
}
(OPTIONS, f)
}
_ => return Err(format!("`{id}` has no envelope dialog")),
};
app.ui.dialog = Some(Dialog::new(kind, fields));
Ok(Value::Null)
}
/// The command OK runs, its parameters and the preview's undo label.
fn command(d: &Dialog) -> (&'static str, Value, &'static str) {
let reset = d.bool("reset");
match d.kind.as_str() {
WARP => {
let p = json!({"style": d.str("style"), "horizontal": d.bool("horizontal"), "bend": d.f64("bend", 50.0), "h": d.f64("h", 0.0), "v": d.f64("v", 0.0)});
if reset { (RESET_WARP, p, "Reset with Warp") } else { (MAKE_WARP, p, "Make Envelope") }
}
MESH => {
let p = json!({"rows": d.f64("rows", 4.0), "cols": d.f64("cols", 4.0)});
if reset {
let mut p = p;
p["maintainShape"] = json!(d.bool("maintainShape"));
(RESET_MESH, p, "Reset with Mesh")
} else {
(MAKE_MESH, p, "Make Envelope")
}
}
_ => {
let mut p = json!({"fidelity": d.f64("fidelity", 50.0), "preserveShape": d.str("preserveShape")});
for k in ["antiAlias", "distortAppearance", "distortLinearGradients", "distortPatternFills"] {
p[k] = json!(d.bool(k));
}
(OPTIONS_CMD, p, "Envelope Options")
}
}
}
fn set(d: &mut Dialog, key: &str, v: Value) {
d.fields.insert(key.into(), v);
}
/// A −100..100 % slider row.
fn percent(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let rail = Tokens::get(ui.ctx()).input_border;
form::slider_w(ui, d, (key, label, LABEL_W), -100.0..=100.0, "%", &move |_| rail);
}
/// A whole number field row clamped to 1..50.
fn count(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
widgets::label_row(ui, label, LABEL_W, |ui| {
if let Some(n) = widgets::plain_field(ui, ("env-count", key), d.f64(key, 4.0), "", 0, 60.0) {
set(d, key, json!(n.round().clamp(1.0, 50.0)));
}
});
}
fn warp_body(ui: &mut egui::Ui, d: &mut Dialog) {
let styles: Vec<(&str, &str)> = WARP_STYLES.iter().map(|(id, label)| (*id, label.trim_end_matches('…'))).collect();
form::choice(ui, d, "style", "Style:", (LABEL_W, 160.0), &styles);
let horizontal = d.bool("horizontal");
widgets::label_row(ui, "", LABEL_W, |ui| {
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
if widgets::radio(ui, label, horizontal == h, true) {
set(d, "horizontal", json!(h));
}
ui.add_space(10.0);
}
});
percent(ui, d, "bend", "Bend:");
ui.add_space(6.0);
widgets::subheader(ui, "Distortion");
percent(ui, d, "h", "Horizontal:");
percent(ui, d, "v", "Vertical:");
}
fn mesh_body(ui: &mut egui::Ui, d: &mut Dialog) {
count(ui, d, "rows", "Rows:");
count(ui, d, "cols", "Columns:");
if d.bool("reset") {
ui.add_space(4.0);
form::check(ui, d, "maintainShape", "Maintain Envelope Shape");
}
}
fn options_body(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::subheader(ui, "Rasters");
ui.horizontal(|ui| {
ui.add_space(12.0);
form::check(ui, d, "antiAlias", "Anti-Alias");
});
ui.add_space(4.0);
widgets::subheader(ui, "Preserve Shape Using:");
let clip = d.str("preserveShape") != "transparency";
ui.horizontal(|ui| {
ui.add_space(12.0);
for (value, label) in [("clippingMask", "Clipping Mask"), ("transparency", "Transparency")] {
if widgets::radio(ui, label, clip == (value == "clippingMask"), true) {
set(d, "preserveShape", json!(value));
}
ui.add_space(10.0);
}
});
ui.add_space(4.0);
let rail = Tokens::get(ui.ctx()).input_border;
form::slider_w(ui, d, ("fidelity", "Fidelity:", LABEL_W), 0.0..=100.0, "", &move |_| rail);
ui.add_space(4.0);
form::check(ui, d, "distortAppearance", "Distort Appearance");
// Gradients and patterns bend only with the appearance.
let appearance = d.bool("distortAppearance");
for (key, label) in [("distortLinearGradients", "Distort Linear Gradients"), ("distortPatternFills", "Distort Pattern Fills")] {
let on = d.bool(key);
ui.horizontal(|ui| {
ui.add_space(20.0);
if widgets::check(ui, label, on, appearance) {
set(d, key, json!(!on));
}
});
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
match d.kind.as_str() {
WARP => warp_body(ui, d),
MESH => mesh_body(ui, d),
_ => options_body(ui, d),
}
// With no envelope selected, Envelope Options sets the defaults: nothing to preview.
if d.kind != OPTIONS || d.bool("__envelope") {
let (cmd, p, label) = command(d);
form::preview(app, ui, d, label, cmd, p);
}
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (cmd, p, _) = command(d);
form::commit_preview(app, cmd, p)
}
/// What the Control bar shows for the selected envelope.
#[derive(Clone, Debug, PartialEq)]
enum Bar {
Warp { style: String, bend: f64, h: f64, v: f64, horizontal: bool },
Mesh { rows: u32, cols: u32 },
TopObject,
}
/// The selected envelope (or the one whose contents are selected), whether its contents are being
/// edited, and its settings.
fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> {
let st = app.session.active()?;
let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?;
let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None };
let bar = match kind {
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal }
}
EnvelopeKind::Mesh { rows, cols, .. } => Bar::Mesh { rows: *rows, cols: *cols },
EnvelopeKind::TopObject { .. } => Bar::TopObject,
};
Some((*editing, bar))
}
/// The Control bar's envelope controls (as in the reference app) while an envelope, or content
/// whose envelope is being edited, is selected: Edit Envelope / Edit Contents; a warp's style,
/// orientation, bend and distortions, or a mesh's rows and columns; Reset Envelope Shape; and
/// Envelope Options.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let Some((editing, bar)) = bar_of(app) else { return };
let mut run: Option<(&str, Value)> = None;
ui.separator();
for (contents, icon, tip) in [(false, "dc-mesh", "Edit Envelope"), (true, "shapes", "Edit Contents")] {
if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents {
run = Some(("object.envelope.editContents", json!({ "editing": contents })));
}
}
ui.add_space(4.0);
let field = |ui: &mut egui::Ui, key: &str, label: &str, value: f64, suffix: &str| {
widgets::dim_label(ui, label);
widgets::plain_field(ui, ("cb-envelope", key), value, suffix, 0, 46.0)
};
match &bar {
Bar::Warp { style, bend, h, v, horizontal } => {
let styles: Vec<&str> = WARP_STYLES.iter().map(|(_, l)| l.trim_end_matches('…')).collect();
let current = WARP_STYLES.iter().find(|(id, _)| id == style).map_or(style.as_str(), |(_, l)| l.trim_end_matches('…'));
widgets::dim_label(ui, "Style:");
if let Some((id, _)) = widgets::dropdown(ui, "cb-envelope-style", current, &styles, 110.0).and_then(|i| WARP_STYLES.get(i)) {
run = Some((OPTIONS_CMD, json!({ "style": id })));
}
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
if widgets::radio(ui, label, *horizontal == h, true) {
run = Some((OPTIONS_CMD, json!({ "horizontal": h })));
}
}
for (key, label, value) in [("bend", "Bend:", *bend), ("h", "H:", *h), ("v", "V:", *v)] {
if let Some(n) = field(ui, key, label, value, "%") {
run = Some((OPTIONS_CMD, json!({ key: n.round().clamp(-100.0, 100.0) })));
}
}
}
Bar::Mesh { rows, cols } => {
for (key, label, value) in [("rows", "Rows:", *rows), ("cols", "Columns:", *cols)] {
if let Some(n) = field(ui, key, label, f64::from(value), "") {
run = Some((RESET_MESH, json!({ key: n.round().clamp(1.0, 50.0), "maintainShape": true })));
}
}
}
Bar::TopObject => {}
}
// Reset Envelope Shape: an unbent warp, a flat mesh.
let reset = match bar {
Bar::Warp { .. } => Some((OPTIONS_CMD, json!({"bend": 0, "h": 0, "v": 0}))),
Bar::Mesh { .. } => Some((RESET_MESH, json!({"maintainShape": false}))),
Bar::TopObject => None,
};
if let Some(r) = reset
&& widgets::flat_button(ui, "Reset", 50.0).on_hover_text("Reset Envelope Shape").clicked()
{
run = Some(r);
}
if widgets::icon_button(ui, "dc-options", "Envelope Options", false, 24.0).clicked() {
crate::menus::invoke(app, OPTIONS_CMD, json!({}));
}
if let Some((cmd, p)) = run
&& let Err(e) = app.run(cmd, p)
{
app.status(e);
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::live::EnvelopeKind;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_engine::Session;
fn app_with_rect() -> (VectorcraftApp, NodeId) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
(app, NodeId(id))
}
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
fn envelope(app: &VectorcraftApp) -> (NodeId, vectorcraft_doc::Node) {
let st = app.session.doc().unwrap();
let id = st.selection.objects[0];
(id, st.doc.node(id).unwrap().clone())
}
#[test]
fn warp_dialog_previews_and_ok_keeps_one_step() {
let (mut app, rect) = app_with_rect();
crate::menus::invoke(&mut app, MAKE_WARP, json!({}));
assert_eq!(app.ui.dialog.as_ref().map(|d| (d.kind.as_str(), d.fields["reset"].clone())), Some((WARP, json!(false))));
frame(&mut app);
// The preview made the envelope already.
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { .. }));
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("style".into(), json!("flag"));
d.fields.insert("horizontal".into(), json!(false));
d.fields.insert("bend".into(), json!(-30));
frame(&mut app);
let undo = app.session.doc().unwrap().history.undo.len();
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let (_, n) = envelope(&app);
match &n.kind {
NodeKind::Envelope { kind: EnvelopeKind::Warp { style, bend, horizontal, .. }, content, .. } => {
assert_eq!((style.as_str(), *bend, *horizontal), ("flag", -30.0, false));
assert_eq!(content[0].id, rect);
}
k => panic!("{k:?}"),
}
// With the envelope selected, the same item resets it, starting from its values.
crate::menus::invoke(&mut app, MAKE_WARP, json!({}));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.fields["reset"].clone(), d.fields["style"].clone(), d.fields["bend"].clone()), (json!(true), json!("flag"), json!(-30.0)));
crate::dialogs::cancel(&mut app);
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }), "cancel rolls back");
}
#[test]
fn reset_items_take_the_make_items_place_while_an_envelope_is_selected() {
let labels = |app: &VectorcraftApp| -> Vec<String> {
crate::menus::menu_entries(app)
.into_iter()
.filter(|e| e.path.last().map(String::as_str) == Some("Envelope Distort"))
.map(|e| e.label)
.collect()
};
let (mut app, _) = app_with_rect();
let before = labels(&app);
assert!(before.contains(&"Make with Warp…".to_string()) && !before.contains(&"Reset with Warp…".to_string()), "{before:?}");
app.run(MAKE_MESH, json!({})).unwrap();
let after = labels(&app);
assert!(after.contains(&"Reset with Warp…".to_string()) && after.contains(&"Reset with Mesh…".to_string()), "{after:?}");
assert!(!after.contains(&"Make with Warp…".to_string()) && !after.contains(&"Make with Mesh…".to_string()), "{after:?}");
assert!(app.run("ui.menuDialog", json!({"command": RESET_WARP})).is_ok());
app.run("select.none", json!({})).unwrap();
assert!(app.run("ui.menuDialog", json!({"command": RESET_WARP})).is_err(), "nothing to reset");
}
#[test]
fn reset_mesh_dialog_switches_the_kind_and_cancel_rolls_back() {
let (mut app, _) = app_with_rect();
app.run(MAKE_WARP, json!({"style": "arc", "bend": 50})).unwrap();
app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(MESH));
frame(&mut app);
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 4, cols: 4, .. }, .. }), "previewed");
crate::dialogs::cancel(&mut app);
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }));
app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), json!(2));
super::super::confirm(&mut app).unwrap();
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 2, cols: 4, .. }, .. }));
}
#[test]
fn options_dialog_starts_from_the_envelope_and_without_one_sets_the_defaults() {
let (mut app, _) = app_with_rect();
app.run(MAKE_WARP, json!({})).unwrap();
app.run("object.envelope.options", json!({"fidelity": 80, "distortPatternFills": true})).unwrap();
crate::menus::invoke(&mut app, OPTIONS_CMD, json!({}));
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.fields["fidelity"].clone(), d.fields["distortPatternFills"].clone()), (json!(80.0), json!(true)));
d.fields.insert("preserveShape".into(), json!("transparency"));
frame(&mut app);
super::super::confirm(&mut app).unwrap();
let info = app.session.execute("object.envelope.info", &json!({})).unwrap();
assert_eq!((info["preserveShape"].clone(), info["fidelity"].clone()), (json!("transparency"), json!(80.0)));
// Nothing selected: the defaults for new envelopes, no preview.
app.run("select.none", json!({})).unwrap();
crate::menus::invoke(&mut app, OPTIONS_CMD, json!({}));
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!(d.fields["preview"], json!(false));
d.fields.insert("distortAppearance".into(), json!(false));
frame(&mut app);
assert!(!app.session.in_interaction());
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.execute("object.envelope.info", &json!({})).unwrap()["distortAppearance"], json!(false));
}
#[test]
fn edit_contents_reads_edit_envelope_while_editing() {
let (mut app, _) = app_with_rect();
app.run(MAKE_WARP, json!({})).unwrap();
let label = |app: &VectorcraftApp| crate::menus::dynamic_label(app, "object.envelope.editContents", "Edit Contents");
assert_eq!(label(&app), "Edit Contents");
app.run("object.envelope.editContents", json!({})).unwrap();
assert_eq!(label(&app), "Edit Envelope");
assert!(crate::menus::shortcut_of("object.envelope.editContents").is_none(), "its reference shortcut is Paste in Place's here");
}
/// One Control bar frame with `events`; the rectangles of its 24 pt icon buttons, left to right.
fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<egui::Rect> {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1800.0, 600.0)));
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| crate::chrome::control_bar(app, ui));
out.textures_delta.clear();
let mut r: Vec<egui::Rect> = ctx.viewport(|vp| {
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == egui::vec2(24.0, 24.0)).map(|w| w.rect).collect()
});
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
r
}
#[test]
fn the_control_bar_switches_to_edit_contents_and_back() {
let (mut app, rect) = app_with_rect();
app.run(MAKE_WARP, json!({"style": "arch"})).unwrap();
assert!(bar_of(&app).is_some_and(|(editing, bar)| !editing && matches!(bar, Bar::Warp { ref style, .. } if style == "arch")));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let click = |app: &mut VectorcraftApp, at: egui::Pos2| {
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
bar_frame(app, &ctx, vec![egui::Event::PointerMoved(at), button(true)]);
bar_frame(app, &ctx, vec![button(false)]);
};
bar_frame(&mut app, &ctx, vec![]);
let buttons = bar_frame(&mut app, &ctx, vec![]);
// Edit Envelope, Edit Contents come first.
click(&mut app, buttons[1].center());
assert_eq!(app.session.doc().unwrap().selection.objects, vec![rect], "Edit Contents selects the content");
assert!(bar_of(&app).is_some_and(|(editing, _)| editing), "the content keeps the envelope's controls");
let buttons = bar_frame(&mut app, &ctx, vec![]);
click(&mut app, buttons[0].center());
assert!(bar_of(&app).is_some_and(|(editing, _)| !editing));
// A mesh envelope shows its rows and columns instead.
app.run(RESET_MESH, json!({"rows": 3, "cols": 2})).unwrap();
assert_eq!(bar_of(&app).map(|b| b.1), Some(Bar::Mesh { rows: 3, cols: 2 }));
bar_frame(&mut app, &ctx, vec![]);
}
}
+227
View File
@@ -0,0 +1,227 @@
//! Liquify Tool Options (double-click a Liquify tool: Warp, Twirl, Pucker, Bloat, Scallop,
//! Crystallize, Wrinkle): the Global Brush Dimensions the seven tools share, the tool's own options
//! and Show Brush Size. OK sets them with `tool.setOption {tool, values}` (they last across tool
//! switches and are saved with the preferences).
//!
//! Fields: `tool`; `width`, `height` (points), `angle` (degrees), `intensity` (%), `usePressure`;
//! `detail` (1–10); `simplify` (1–100) and `simplifyOn` (Warp, Twirl, Pucker, Bloat); `rate`
//! (Twirl, −180–180°); `complexity` (0–15), `affectAnchors`, `affectIn`, `affectOut` (Scallop,
//! Crystallize, Wrinkle); `horizontal`, `vertical` (Wrinkle, %); `showBrush`.
use serde_json::{Map, Value, json};
use vectorcraft_tools::distort::liquify::LiquifyKind;
use super::document_setup::check;
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of the Liquify Tool Options.
pub const KIND: &str = "liquifyOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 360.0, ..DialogSpec::FORM };
/// Width of the label column.
const LABEL_W: f32 = 104.0;
/// The options shown as percentages (the tool keeps them as 0..1).
const PERCENT: [&str; 3] = ["intensity", "horizontal", "vertical"];
/// The options the dialog edits (the tool's options; `tool` names it).
const KEYS: [&str; 16] = [
"width",
"height",
"angle",
"intensity",
"usePressure",
"detail",
"simplify",
"simplifyOn",
"rate",
"complexity",
"horizontal",
"vertical",
"affectAnchors",
"affectIn",
"affectOut",
"showBrush",
];
fn kind(d: &Dialog) -> LiquifyKind {
LiquifyKind::parse(&d.str("tool")).unwrap_or(LiquifyKind::Warp)
}
fn heading(d: &Dialog) -> String {
format!("{} Tool Options", kind(d).label())
}
/// Open the options of Liquify tool `tool` with its current values.
pub fn open(app: &mut VectorcraftApp, tool: &str) -> Result<Value, String> {
let k = LiquifyKind::parse(tool).ok_or_else(|| format!("`{tool}` isn't a Liquify tool"))?;
let o = app.session.tool_options_of(k.id());
let mut fields: Map<String, Value> = KEYS.iter().filter_map(|key| Some((key.to_string(), o.get(*key)?.clone()))).collect();
for key in PERCENT {
let v = fields.get(key).and_then(Value::as_f64).unwrap_or(0.0);
fields.insert(key.into(), json!((v * 100.0).round()));
}
fields.insert("tool".into(), json!(k.id()));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
Ok(json!({ "dialog": KIND }))
}
/// A slider row for the whole number `d.fields[key]`.
fn slider(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), range: std::ops::RangeInclusive<f64>, suffix: &str) {
let rail = Tokens::get(ui.ctx()).input_border;
form::slider_w(ui, d, (key, label, LABEL_W), range, suffix, &|_| rail);
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let k = kind(d);
let unit = app.session.general_unit();
widgets::subheader(ui, "Global Brush Dimensions");
ui.add_space(4.0);
for (key, label) in [("width", "Width:"), ("height", "Height:")] {
widgets::label_row(ui, label, LABEL_W, |ui| {
form::length(ui, d, key, unit, 110.0);
});
}
widgets::label_row(ui, "Angle:", LABEL_W, |ui| {
if let Some(v) = widgets::plain_field(ui, "lq-angle", d.f64("angle", 0.0), "°", 1, 110.0) {
d.fields.insert("angle".into(), json!(v.clamp(-360.0, 360.0)));
}
});
let pressure = d.bool("usePressure");
widgets::label_row(ui, "Intensity:", LABEL_W, |ui| {
ui.add_enabled_ui(!pressure, |ui| {
if let Some(v) = widgets::plain_field(ui, "lq-intensity", d.f64("intensity", 50.0), "%", 0, 110.0) {
d.fields.insert("intensity".into(), json!(v.clamp(1.0, 100.0)));
}
});
});
widgets::label_row(ui, "", LABEL_W, |ui| check(ui, d, "usePressure", "Use Pressure Pen"));
ui.add_space(10.0);
widgets::subheader(ui, &format!("{} Options", k.label()));
ui.add_space(4.0);
if k == LiquifyKind::Twirl {
slider(ui, d, ("rate", "Twirl Rate:"), -180.0..=180.0, "°");
}
if k == LiquifyKind::Wrinkle {
slider(ui, d, ("horizontal", "Horizontal:"), 0.0..=100.0, "%");
slider(ui, d, ("vertical", "Vertical:"), 0.0..=100.0, "%");
}
if k.has_affects() {
slider(ui, d, ("complexity", "Complexity:"), 0.0..=15.0, "");
}
slider(ui, d, ("detail", "Detail:"), 1.0..=10.0, "");
if k.simplifies() {
widgets::label_row(ui, "", LABEL_W, |ui| check(ui, d, "simplifyOn", "Simplify"));
let on = d.bool("simplifyOn");
ui.add_enabled_ui(on, |ui| slider(ui, d, ("simplify", "Simplify:"), 1.0..=100.0, ""));
} else {
ui.add_space(4.0);
widgets::label_row(ui, "Brush Affects:", LABEL_W, |ui| {
ui.vertical(|ui| {
for (key, label) in [("affectAnchors", "Anchor Points"), ("affectIn", "In Tangent Handles"), ("affectOut", "Out Tangent Handles")] {
check(ui, d, key, label);
}
});
});
}
ui.add_space(10.0);
check(ui, d, "showBrush", "Show Brush Size");
let hint = format!("{} with the tool sizes the brush", crate::menus::pretty_shortcut("Alt+Drag"));
ui.label(egui::RichText::new(hint).size(11.5).color(Tokens::get(ui.ctx()).text_dim));
false
}
/// OK: set the tool's options (percentages back to 0..1).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut values: Map<String, Value> = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect();
for key in PERCENT {
if values.contains_key(key) {
values.insert(key.into(), json!(d.f64(key, 0.0) / 100.0));
}
}
run_and_close(app, "tool.setOption", json!({ "tool": kind(d).id(), "values": values }))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app
}
#[test]
fn each_tool_shows_its_options_and_ok_sets_them() {
let mut app = app();
app.select_tool("twirl");
app.run("tool.options", json!({"tool": "twirl"})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx()));
for label in [
"Twirl Tool Options",
"Global Brush Dimensions",
"Width:",
"Height:",
"Angle:",
"Intensity:",
"Use Pressure Pen",
"Twirl Options",
"Twirl Rate:",
"Detail:",
"Simplify",
"Show Brush Size",
] {
assert!(text.contains(label), "{label} in {text}");
}
assert!(!text.contains("Brush Affects:") && !text.contains("Complexity:"), "{text}");
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.f64("intensity", 0.0), d.f64("rate", 0.0), d.bool("showBrush")), (50.0, 40.0, true));
for (k, v) in
[("width", json!(80)), ("intensity", json!(30)), ("rate", json!(-120)), ("simplifyOn", json!(false)), ("showBrush", json!(false))]
{
d.fields.insert(k.into(), v);
}
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let o = app.session.tool_options();
assert_eq!((o["width"].as_f64(), o["intensity"].as_f64(), o["rate"].as_f64()), (Some(80.0), Some(0.3), Some(-120.0)));
assert_eq!((o["simplifyOn"].as_bool(), o["showBrush"].as_bool()), (Some(false), Some(false)));
// The brush is shared: the Warp tool has it too, though it is not the active tool.
app.run("tool.options", json!({"tool": "warp"})).unwrap();
assert_eq!((app.ui.dialog.as_ref().unwrap().f64("width", 0.0), app.ui.dialog.as_ref().unwrap().f64("intensity", 0.0)), (80.0, 30.0));
}
#[test]
fn scallop_crystallize_and_wrinkle_have_brush_affects_and_no_simplify() {
let mut app = app();
for (tool, extra) in [("scallop", None), ("crystallize", None), ("wrinkle", Some("Horizontal:"))] {
app.run("tool.options", json!({"tool": tool})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx()));
for label in ["Complexity:", "Detail:", "Brush Affects:", "Anchor Points", "In Tangent Handles", "Out Tangent Handles"] {
assert!(text.contains(label), "{tool}: {label} in {text}");
}
assert!(!text.contains("Simplify"), "{tool}: {text}");
assert_eq!(text.contains("Horizontal:"), extra.is_some(), "{tool}");
app.ui.dialog.as_mut().unwrap().fields.insert("affectIn".into(), json!(false));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.tool_options_of(tool)["affectIn"], json!(false), "{tool}");
}
}
#[test]
fn type_under_the_brush_is_reported_in_the_status_bar() {
let mut app = app();
app.run("text.create", json!({"x": 100, "y": 100, "text": "Hi"})).unwrap();
app.run("select.set", json!({"ids": []})).unwrap();
app.select_tool("bloat");
let view = app.view_info();
crate::canvas::dispatch(&mut app, &vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Down, 105.0, 95.0), view);
assert!(app.ui.status.contains("Bloat left 1 object under the brush"), "{}", app.ui.status);
}
}
+17
View File
@@ -9,6 +9,7 @@
mod about;
mod all_tools;
mod artboard_options;
pub mod blend_options;
pub mod color_balance;
pub mod color_guide_options;
mod color_picker;
@@ -18,6 +19,7 @@ mod document_setup;
pub mod dxf_import;
pub mod dxf_options;
mod effect;
pub mod envelope;
pub mod eps_options;
pub mod expand;
mod export_as;
@@ -31,6 +33,7 @@ mod gradient_stop;
pub mod graphic_style_options;
pub mod halftone;
pub mod import_pdf;
pub mod liquify;
pub mod missing_links;
pub mod new_color_group;
mod new_document;
@@ -39,6 +42,10 @@ pub mod office_export;
pub mod package;
mod path_ops;
pub mod pdf_presets;
pub mod perspective_grid;
pub mod perspective_options;
pub mod perspective_plane;
pub mod perspective_presets;
pub mod place;
pub mod placement_options;
pub mod plugin;
@@ -249,6 +256,13 @@ registry! {
PrintPresets: [print_presets::KIND] => print_presets::SPEC,
Plugin: [plugin::KIND] => plugin::SPEC,
VectorHalftone: [halftone::KIND] => halftone::SPEC,
PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC,
Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC,
LiquifyOptions: [liquify::KIND] => liquify::SPEC,
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
@@ -392,3 +406,6 @@ mod tests_print_advanced;
#[cfg(test)]
mod tests_scale;
#[cfg(test)]
mod tests_perspective;
@@ -0,0 +1,338 @@
//! View → Perspective Grid → Define Grid…: the grid's type, units and scale, gridline spacing,
//! viewing angle and distance, horizon height and third vanishing point, and the planes' gridline
//! colours and opacity, prefilled from the grid (`perspective.grid.get`). OK runs
//! `perspective.grid.define`: one undo step, nothing when nothing changed.
//!
//! The Preset menu loads a preset's fields (it reads [Custom] once they differ); Save Preset…
//! asks for a name and saves the fields as a preset (`perspective.presets.save`), then comes back.
//! The same dialog is View → Perspective Grid → Save Grid as Preset… and the preset editor of
//! Edit → Perspective Grid Presets (New…, Edit…), whose OK returns to the presets.
//!
//! Fields: those of `perspective.grid.define` (lengths are real-world lengths in `units`; `name`
//! the preset), plus `__mode` (`save`, `edit` or none for Define Grid), `__from` (`define`: Save
//! Preset… returns to Define Grid) and `__original` (the saved preset an edit changes).
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_tools::distort::perspective::{GridDefinition, Rgb};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Define Perspective Grid.
pub const KIND: &str = "perspectiveGrid";
/// The label column's width.
const LABEL: f32 = 140.0;
/// A value field's width.
const FIELD: f32 = 120.0;
/// The unit the lengths were last shown in.
const SHOWN: &str = "__units";
/// What the dialog is: Define Grid (none), `save` (Save Grid as Preset) or `edit` (the preset
/// editor).
const MODE: &str = "__mode";
const FROM: &str = "__from";
const ORIGINAL: &str = "__original";
/// The Preset menu's entry for fields no preset has.
const CUSTOM: &str = "[Custom]";
/// The lengths among the fields: real-world lengths in `units`.
const LENGTHS: [&str; 3] = ["gridline", "distance", "horizonHeight"];
/// The scale choices: (label, [artboard, real world]).
const SCALES: [(&str, [f64; 2]); 9] = [
("1:1", [1.0, 1.0]),
("1:2", [1.0, 2.0]),
("1:4", [1.0, 4.0]),
("1:8", [1.0, 8.0]),
("1:12", [1.0, 12.0]),
("1:24", [1.0, 24.0]),
("1:48", [1.0, 48.0]),
("1:96", [1.0, 96.0]),
("1:100", [1.0, 100.0]),
];
const TYPES: [&str; 3] = ["One Point Perspective", "Two Point Perspective", "Three Point Perspective"];
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| {
match d.str(MODE).as_str() {
"save" => "Save Grid as Preset",
"edit" => "Perspective Grid Preset",
_ => "Define Perspective Grid",
}
.into()
},
body,
confirm,
min_width: 420.0,
max_width: Some(460.0),
..DialogSpec::FORM
};
/// Open the dialog on `def`'s fields as `mode` (`""`, `save` or `edit`).
fn open_on(app: &mut VectorcraftApp, mut def: Value, mode: &str) {
def[SHOWN] = def["units"].clone();
def[MODE] = json!(mode);
app.ui.dialog = Some(Dialog::new(KIND, def));
}
/// The active document's grid as Define Grid fields.
fn grid_fields(app: &mut VectorcraftApp) -> Result<Value, String> {
Ok(app.session.execute("perspective.grid.get", &json!({})).map_err(|e| e.to_string())?["define"].take())
}
/// Open Define Grid on the active document's grid.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let fields = grid_fields(app)?;
open_on(app, fields, "");
Ok(Value::Null)
}
/// View → Perspective Grid → Save Grid as Preset…: the grid's fields under a new preset name.
pub fn open_save(app: &mut VectorcraftApp) -> Result<Value, String> {
let mut fields = grid_fields(app)?;
fields["name"] = json!(app.session.new_perspective_preset_name());
open_on(app, fields, "save");
Ok(Value::Null)
}
/// The preset editor: the saved preset `original` (Edit…), or a new preset starting from `base`
/// (New…). Its OK returns to Edit → Perspective Grid Presets.
pub fn open_edit(app: &mut VectorcraftApp, original: Option<&str>, base: &str) -> Result<(), String> {
let name = original.unwrap_or(base);
let mut def = json!(app.session.perspective_preset(name).ok_or_else(|| format!("no perspective grid preset named `{name}`"))?);
match original {
Some(n) => def[ORIGINAL] = json!(n),
None => def["name"] = json!(app.session.new_perspective_preset_name()),
}
open_on(app, def, "edit");
Ok(())
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut params = form::params(d);
match d.str(MODE).as_str() {
"save" | "edit" => {
if let Some(original) = d.fields.get(ORIGINAL).and_then(Value::as_str) {
params["newName"] = params["name"].take();
params["name"] = json!(original);
}
let r = app.run("perspective.presets.save", params)?;
let name = r["name"].as_str().unwrap_or_default().to_string();
if d.str(MODE) == "edit" {
super::perspective_presets::open(app, Some(&name));
} else if d.str(FROM) == "define" {
// Back to Define Grid, on the preset just saved.
if let Some(back) = app.ui.dialog.as_mut() {
back.fields.remove(MODE);
back.fields.remove(FROM);
back.fields.insert("name".into(), json!(name));
}
} else {
app.ui.dialog = None;
}
Ok(r)
}
_ => {
let r = app.run("perspective.grid.define", params);
if r.is_ok() {
app.ui.dialog = None;
}
r
}
}
}
/// The unit the lengths are in.
fn unit(d: &Dialog) -> Unit {
Unit::named(&d.str("units")).unwrap_or_default()
}
fn kind(d: &Dialog) -> u8 {
d.fields.get("kind").and_then(Value::as_u64).map_or(2, |k| k.clamp(1, 3) as u8)
}
fn scale(d: &Dialog) -> [f64; 2] {
let v = d.fields.get("scale").and_then(Value::as_array);
let at = |i: usize| v.and_then(|a| a.get(i)).and_then(Value::as_f64).unwrap_or(1.0);
[at(0), at(1)]
}
/// A length field (`key`, in `unit`) `width` wide. Returns true when it changed.
fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, width: f32) -> bool {
let pt = d.fields.get(key).and_then(Value::as_f64).map(|v| unit.to_pt(v));
let Some(v) = widgets::num_field(ui, ("persp-len", key), pt, unit, width) else { return false };
d.fields.insert(key.into(), json!(unit.from_pt(v)));
true
}
/// One of the third vanishing point's coordinates (`i`: 0 = x, 1 = y).
fn third_vp(ui: &mut egui::Ui, d: &mut Dialog, i: usize, unit: Unit, enabled: bool) {
let mut v = d.fields.get("thirdVp").and_then(Value::as_array).cloned().unwrap_or_else(|| vec![json!(0), json!(0)]);
v.resize(2, json!(0));
let pt = v.get(i).and_then(Value::as_f64).map(|x| unit.to_pt(x));
ui.label(if i == 0 { "X:" } else { "Y:" });
if let Some(x) = ui.add_enabled_ui(enabled, |ui| widgets::num_field(ui, ("persp-vp3", i), pt, unit, 84.0)).inner {
v[i] = json!(unit.from_pt(x));
d.fields.insert("thirdVp".into(), Value::Array(v));
}
}
/// A plane's gridline colour: a chip and its hex.
fn color(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let t = Tokens::get(ui.ctx());
let cur = Rgb::parse(&d.str(key));
widgets::label_row(ui, label, LABEL, |ui| {
let (chip, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
if let Some(Rgb([r, g, b])) = cur {
ui.painter().rect_filled(chip, 2.0, egui::Color32::from_rgb(r, g, b));
}
ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
let hex = cur.map(|c| c.hex()).unwrap_or_default();
if let Some(c) = widgets::hex_field(ui, ("persp-color", key), hex.trim_start_matches('#')).as_deref().and_then(Rgb::parse) {
d.fields.insert(key.into(), json!(c.hex()));
}
});
}
/// The Name row: in Define Grid the Preset menu (choosing a preset loads its fields) and Save
/// Preset…, else the preset's name.
fn name_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
if !d.str(MODE).is_empty() {
widgets::label_row(ui, "Name:", LABEL, |ui| {
form::text(ui, d, "name", 200.0);
});
return;
}
let presets = app.session.perspective_presets();
let name = d.str("name");
// The fields no longer the named preset's: [Custom].
let fields: Option<GridDefinition> = serde_json::from_value(form::params(d)).ok();
let current = presets.iter().find(|p| p.name == name).filter(|p| fields.as_ref().is_some_and(|f| f.same(p)));
if current.is_none() && !name.is_empty() {
d.fields.insert("name".into(), json!(""));
}
let shown = current.map_or(CUSTOM, |p| p.name.as_str());
let names: Vec<&str> = std::iter::once(CUSTOM).chain(presets.iter().map(|p| p.name.as_str())).collect();
widgets::label_row(ui, "Preset:", LABEL, |ui| {
if let Some(p) = widgets::dropdown(ui, "persp-preset", shown, &names, 200.0).and_then(|i| i.checked_sub(1)).and_then(|i| presets.get(i)) {
let mut fields = json!(p);
fields[SHOWN] = fields["units"].clone();
if let Some(o) = fields.as_object() {
d.fields.extend(o.clone());
}
}
if widgets::flat_button(ui, "Save Preset…", 96.0).on_hover_text("Save these settings as a preset").clicked() {
d.fields.insert(MODE.into(), json!("save"));
d.fields.insert(FROM.into(), json!("define"));
d.fields.insert("name".into(), json!(app.session.new_perspective_preset_name()));
}
});
ui.add_space(8.0);
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
name_row(app, ui, d);
widgets::subheader(ui, "Perspective Grid Settings");
ui.add_space(6.0);
let k = kind(d);
widgets::label_row(ui, "Type:", LABEL, |ui| {
if let Some(i) = widgets::dropdown(ui, "persp-type", TYPES[usize::from(k - 1)], &TYPES, 200.0) {
d.fields.insert("kind".into(), json!(i + 1));
}
});
let units: Vec<(&str, &str)> = Unit::ALL.iter().filter(|u| **u != Unit::FeetInches).map(|u| (u.key(), u.label())).collect();
form::choice(ui, d, "units", "Units:", (LABEL, 200.0), &units);
// A new unit (the menu or `ui.dialog.set`) keeps the lengths: they are converted.
let unit = unit(d);
let before = Unit::named(&d.str(SHOWN)).unwrap_or(unit);
d.fields.insert(SHOWN.into(), json!(unit.key()));
if unit != before {
let f = before.points() / unit.points();
for key in LENGTHS {
if let Some(v) = d.fields.get(key).and_then(Value::as_f64) {
d.fields.insert(key.into(), json!(v * f));
}
}
if let Some(v) = d.fields.get("thirdVp").and_then(Value::as_array) {
let v: Vec<f64> = v.iter().map(|x| x.as_f64().unwrap_or(0.0) * f).collect();
d.fields.insert("thirdVp".into(), json!(v));
}
}
let sc = scale(d);
let named = SCALES.iter().find(|(_, s)| *s == sc).map(|(l, _)| *l);
let mut labels: Vec<&str> = SCALES.iter().map(|(l, _)| *l).collect();
labels.push("Custom");
widgets::label_row(ui, "Scale:", LABEL, |ui| {
if let Some(i) = widgets::dropdown(ui, "persp-scale", named.unwrap_or("Custom"), &labels, 200.0) {
// Custom starts from the scale shown.
let s = SCALES.get(i).map_or(sc, |(_, s)| *s);
d.fields.insert("scale".into(), json!(s));
}
});
if named.is_none() {
widgets::label_row(ui, "", LABEL, |ui| {
for (i, label) in ["Artboard:", "Real World:"].into_iter().enumerate() {
ui.label(egui::RichText::new(label).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("persp-scale", i), sc[i], "", 3, 56.0).filter(|v| *v > 0.0 && v.is_finite()) {
let mut s = sc;
s[i] = v;
d.fields.insert("scale".into(), json!(s));
}
}
});
}
widgets::label_row(ui, "Gridline every:", LABEL, |ui| {
length(ui, d, "gridline", unit, FIELD);
});
if sc[0] != sc[1] {
let real = unit.number(unit.to_pt(sc[1] / sc[0]));
let note = format!("1 {s} on the artboard stands for {real} {s} in the scene.", s = unit.suffix());
widgets::label_row(ui, "", LABEL, |ui| {
ui.label(egui::RichText::new(note).size(11.5).color(t.text_dim));
});
}
ui.add_space(4.0);
widgets::label_row(ui, "Viewing Angle:", LABEL, |ui| {
let a = d.f64("angle", 45.0);
if let Some(v) = ui.add_enabled_ui(k != 1, |ui| widgets::plain_field(ui, "persp-angle", a, "°", 2, FIELD)).inner {
d.fields.insert("angle".into(), json!(v));
}
});
widgets::label_row(ui, "Viewing Distance:", LABEL, |ui| {
length(ui, d, "distance", unit, FIELD);
});
widgets::label_row(ui, "Horizon Height:", LABEL, |ui| {
length(ui, d, "horizonHeight", unit, FIELD);
});
widgets::label_row(ui, "Third Vanishing Point:", LABEL, |ui| {
third_vp(ui, d, 0, unit, k == 3);
third_vp(ui, d, 1, unit, k == 3);
});
ui.add_space(12.0);
widgets::subheader(ui, "Grid Color & Opacity");
ui.add_space(6.0);
color(ui, d, "leftColor", "Left Grid:");
color(ui, d, "rightColor", "Right Grid:");
color(ui, d, "groundColor", "Horizontal Grid:");
// Opacity: a slider and its field, in the label column the rows above use.
widgets::label_row(ui, "Opacity:", LABEL, |ui| {
let v = d.f64("opacity", 50.0).clamp(0.0, 100.0);
let track = |x: f32| egui::Color32::from_gray((60.0 + x * 160.0) as u8);
let mut new =
widgets::color_slider(ui, "persp-opacity", (v / 100.0) as f32, form::SLIDER_WIDTH, &track).0.map(|x| (f64::from(x) * 100.0).round());
if let Some(x) = widgets::plain_field(ui, "persp-opacity-field", v, "%", 0, 52.0) {
new = Some(x.round().clamp(0.0, 100.0));
}
if let Some(n) = new.filter(|n| *n != v) {
d.fields.insert("opacity".into(), json!(n));
}
});
false
}
@@ -0,0 +1,42 @@
//! Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane Switching
//! Widget shows and which corner of the window it sits in. OK runs `perspective.widget.options`
//! (the options live in the preferences).
//!
//! Fields: `show` (bool) and `position` (`topLeft`, `topRight`, `bottomLeft`, `bottomRight`).
use serde_json::{Value, json};
use vectorcraft_tools::distort::perspective::widget::WidgetCorner;
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Perspective Grid Options.
pub const KIND: &str = "perspectiveGridOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Perspective Grid Options".into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM };
/// Open the dialog with the current options.
pub fn open(app: &mut VectorcraftApp) {
app.ui.dialog = Some(Dialog::new(KIND, json!(app.session.prefs.perspective_widget)));
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let r = app.run("perspective.widget.options", form::params(d));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let on = d.bool("show");
if widgets::check(ui, "Show Active Plane Widget", on, true) {
d.fields.insert("show".into(), json!(!on));
}
ui.add_space(8.0);
let corners: Vec<(&str, &str)> = WidgetCorner::ALL.iter().map(|c| (c.id(), c.label())).collect();
ui.add_enabled_ui(on, |ui| form::choice(ui, d, "position", "Widget Position:", (110.0, 160.0), &corners));
false
}
@@ -0,0 +1,149 @@
//! Perspective plane options: double-clicking a plane widget of the perspective grid (or
//! `ui.perspectivePlane {plane}`) moves that plane to an exact location, alone, with the objects on
//! it, or with copies of them. OK runs `perspective.plane.move`.
//!
//! Fields: `plane` (`left`, `right` or `ground`), `location` (points along the plane's normal; 0
//! is its place in the grid's definition), `objects` (`none`, `move` or `copy`).
use serde_json::{Value, json};
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
use super::swatch_options::{grid, label};
use super::{DialogSpec, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of the plane options.
pub const KIND: &str = "perspectivePlane";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 300.0, ..DialogSpec::FORM };
/// What becomes of the objects on the plane: (`objects` value, label).
const OBJECTS: [(&str, &str); 3] = [("none", "Do Not Move"), ("move", "Move All Objects"), ("copy", "Copy All Objects")];
fn heading(d: &Dialog) -> String {
match Plane::parse(&d.str("plane")) {
Some(Plane::Right) => "Right Plane",
Some(Plane::Ground) => "Horizontal Plane",
_ => "Left Plane",
}
.into()
}
/// Open the options of `plane` (`{plane}`; default the active plane), filled in with its location.
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let doc = &app.session.active().ok_or("no document")?.doc;
let g = PerspectiveGrid::current(doc);
let plane = match p.get("plane").and_then(Value::as_str) {
Some(s) => Plane::parse(s).ok_or("plane must be left, right or ground")?,
None => g.plane,
};
if plane == Plane::None {
return Err("no active perspective plane".into());
}
let fields = json!({"plane": plane.id(), "location": g.offset(plane), "objects": "none"});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
grid(ui, |ui| {
label(ui, "Location:");
if let Some(v) = widgets::num_field(ui, "pp-location", Some(d.f64("location", 0.0)), unit, 120.0) {
d.fields.insert("location".into(), json!(v));
}
ui.end_row();
});
ui.add_space(6.0);
let cur = d.str("objects");
for (value, text) in OBJECTS {
if widgets::radio(ui, text, cur == value || (cur.is_empty() && value == "none"), true) {
d.fields.insert("objects".into(), json!(value));
}
}
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let objects = Some(d.str("objects")).filter(|o| OBJECTS.iter().any(|(v, _)| v == o)).unwrap_or_else(|| "none".into());
let p = json!({"plane": d.str("plane"), "offset": d.f64("location", 0.0), "objects": objects});
run_and_close(app, "perspective.plane.move", p)
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
use vectorcraft_tools::{PointerEvent, PointerKind};
fn shown(app: &mut VectorcraftApp) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
// A new window lays itself out in its first frame and paints in the next.
ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())).textures_delta.clear();
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
#[test]
fn double_clicking_a_plane_widget_opens_its_options_and_ok_moves_it() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 800, "height": 600})).unwrap();
app.run("perspective.grid.preset", json!({"kind": 2})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone();
app.run("perspective.attach", json!({"ids": [id], "plane": "right"})).unwrap();
let g = PerspectiveGrid::current(&app.session.active().unwrap().doc);
let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap();
app.select_tool("perspectiveGrid");
let view = app.view_info();
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::DoubleClick, w.x, w.y), view);
let d = app.ui.dialog.clone().expect("the plane's options opened");
assert_eq!((d.kind.as_str(), d.str("plane").as_str(), d.f64("location", 1.0)), (KIND, "right", 0.0));
let texts = shown(&mut app);
for s in ["Right Plane", "Location:", "Do Not Move", "Move All Objects", "Copy All Objects"] {
assert!(texts.iter().any(|t| t == s), "{s} in {texts:?}");
}
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("location".into(), json!(40));
d.fields.insert("objects".into(), json!("copy"));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let doc = &app.session.active().unwrap().doc;
assert_eq!(PerspectiveGrid::current(doc).right_offset, 40.0);
assert_eq!(doc.layers[0].children().unwrap().len(), 2, "copied");
// The UI command opens it too; unknown planes are refused.
app.run("ui.perspectivePlane", json!({"plane": "ground"})).unwrap();
assert_eq!(heading(app.ui.dialog.as_ref().unwrap()), "Horizontal Plane");
assert!(app.run("ui.perspectivePlane", json!({"plane": "up"})).is_err());
}
#[test]
fn a_click_to_size_rectangle_goes_on_the_active_plane() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 800, "height": 600})).unwrap();
app.run("perspective.grid.preset", json!({"kind": 2})).unwrap();
app.run("perspective.plane.set", json!({"plane": "right"})).unwrap();
app.select_tool("rectangle");
let view = app.view_info();
for kind in [PointerKind::Down, PointerKind::Up] {
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 470.0, 300.0), view);
}
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("rectangle"));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.journal.last().unwrap().0, "perspective.draw");
let st = app.session.active().unwrap();
let id = *st.selection.objects.first().unwrap();
assert_eq!(vectorcraft_tools::distort::perspective::attachment(st.doc.node(id).unwrap()).map(|a| a.0), Some(Plane::Right));
}
}
@@ -0,0 +1,224 @@
//! Edit → Perspective Grid Presets…: every preset in a list (the built-in views, protected, then
//! the saved ones) with the selected one's settings beside it; New… (a preset starting from the
//! selected one), Edit… (saved presets), Delete, Import… and Export… (the selected preset) under
//! the list. New… and Edit… open the preset editor (the Define Grid dialog, mode `edit`), whose OK
//! comes back here.
//!
//! Also View → Perspective Grid → One/Two/Three Point Perspective: the built-in views of the type
//! and slots for the saved presets of that type ([`SLOT`]).
//!
//! Field: `selected` (a preset's name).
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_engine::cmd::perspgrid::{PRESET_EXTS, PRESET_FORMAT};
use vectorcraft_tools::distort::perspective::GridDefinition;
use vectorcraft_tools::distort::perspective::define::{BUILTINS, is_builtin};
use super::DialogSpec;
use crate::menus::Item;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Perspective Grid Presets.
pub const KIND: &str = "perspectiveGridPresets";
/// The id prefix of the saved-preset slots in the One/Two/Three Point Perspective menus:
/// `ui.perspectiveUserPreset<kind>.<n>` is the n-th saved preset of that type.
pub const SLOT: &str = "ui.perspectiveUserPreset";
/// Saved-preset slots per type.
pub const SLOTS: usize = 5;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Perspective Grid Presets".into(),
body,
confirm: |app, _| {
app.ui.dialog = None;
Ok(Value::Null)
},
ok: None,
min_width: 600.0,
max_width: Some(600.0),
..DialogSpec::FORM
};
/// Open the presets manager on `selected` (default: the first preset).
pub fn open(app: &mut VectorcraftApp, selected: Option<&str>) {
app.ui.dialog = Some(Dialog::new(KIND, json!({ "selected": selected.unwrap_or(BUILTINS[0].0) })));
}
/// What a button asked for, done once the list and the details are drawn.
pub(super) enum Action {
Select(String),
New,
Edit,
Delete,
Import,
Export,
}
/// The settings rows of a preset: (label, value).
fn details(p: &GridDefinition) -> Vec<(&'static str, String)> {
let u = p.unit();
let len = |v: f64| u.format(u.to_pt(v));
let mut rows = vec![
("Type:", ["One Point", "Two Point", "Three Point"].get(usize::from(p.kind.clamp(1, 3) - 1)).copied().unwrap_or_default().to_string()),
("Units:", u.label().to_string()),
("Scale:", format!("{}:{}", Unit::Points.number(p.scale[0]), Unit::Points.number(p.scale[1]))),
("Gridline every:", len(p.gridline)),
];
if p.kind != 1 {
rows.push(("Viewing Angle:", format!("{:.1}°", p.angle)));
}
rows.extend([("Viewing Distance:", len(p.distance)), ("Horizon Height:", len(p.horizon_height))]);
if p.kind == 3 {
rows.push(("Third Vanishing Point:", format!("{}, {}", len(p.third_vp[0]), len(p.third_vp[1]))));
}
rows.push(("Opacity:", format!("{:.0}%", p.opacity)));
rows
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let presets = app.session.perspective_presets();
let selected = d.str("selected");
let i = presets.iter().position(|p| p.name.eq_ignore_ascii_case(selected.trim())).unwrap_or(0);
let Some(preset) = presets.get(i) else { return false };
let builtin = is_builtin(&preset.name);
let mut act = None;
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(260.0);
widgets::dim_label(ui, "Presets:");
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("perspective-presets").min_scrolled_height(240.0).max_height(240.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(240.0);
for (k, p) in presets.iter().enumerate() {
if ui.selectable_label(k == i, &p.name).clicked() {
act = Some(Action::Select(p.name.clone()));
}
}
});
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if widgets::flat_button(ui, "New…", 48.0).on_hover_text("A new preset starting from the selected one").clicked() {
act = Some(Action::New);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Edit…", 48.0)).inner.clicked() {
act = Some(Action::Edit);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() {
act = Some(Action::Delete);
}
if widgets::flat_button(ui, "Import…", 60.0).clicked() {
act = Some(Action::Import);
}
if widgets::flat_button(ui, "Export…", 60.0).on_hover_text("Save the selected preset to a file").clicked() {
act = Some(Action::Export);
}
});
});
ui.add_space(18.0);
ui.vertical(|ui| {
ui.set_width(280.0);
ui.label(egui::RichText::new(&preset.name).color(t.text_strong).strong());
ui.add_space(8.0);
egui::Grid::new("perspective-preset-details").num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| {
for (label, value) in details(preset) {
ui.label(egui::RichText::new(label).color(t.text_dim));
ui.label(egui::RichText::new(value).color(t.text));
ui.end_row();
}
});
if builtin {
ui.add_space(6.0);
ui.label(egui::RichText::new("Built-in presets don't change: New… starts an editable copy.").color(t.text_dim).size(11.5));
}
});
});
let name = preset.name.clone();
if let Some(a) = act
&& let Err(e) = run(app, d, a, &name)
{
app.status(e);
}
false
}
fn select(d: &mut Dialog, name: &str) {
d.fields.insert("selected".into(), json!(name));
}
pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> Result<(), String> {
match act {
Action::Select(name) => select(d, &name),
// The editor replaces this dialog; its OK comes back here.
Action::New | Action::Edit => {
let original = matches!(act, Action::Edit).then_some(current);
super::perspective_grid::open_edit(app, original, current)?;
*d = app.ui.dialog.take().ok_or("no preset editor")?;
}
Action::Delete => {
let i = app.session.prefs.perspective_presets.iter().position(|p| p.name == current);
app.run("perspective.presets.delete", json!({ "name": current }))?;
// The one above it takes its place (the last built-in view above the first saved one).
let saved = &app.session.prefs.perspective_presets;
let next =
i.and_then(|i| i.checked_sub(1)).and_then(|i| saved.get(i)).map_or(BUILTINS[BUILTINS.len() - 1].0, |p| p.name.as_str()).to_string();
select(d, &next);
}
Action::Import => {
// On the web the picked file arrives later and imports through `io::open_bytes`.
if let Some(f) = app.services.open_async.as_mut() {
f();
return Ok(());
}
let pick = crate::FilePick { filters: vec![("Perspective grid presets", PRESET_EXTS)], ..Default::default() };
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
let r = app.run("perspective.presets.import", json!({ "path": path }))?;
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
select(d, first);
}
}
Action::Export => {
crate::io::save_command_output(app, "perspective.presets.export", PRESET_FORMAT, json!({ "names": [current] }))?;
}
}
Ok(())
}
// ---------- the One/Two/Three Point Perspective menus ----------
/// The saved preset a slot id (`ui.perspectiveUserPreset2.1`) stands for.
pub fn slot_preset(app: &VectorcraftApp, id: &str) -> Option<String> {
let (kind, n) = id.strip_prefix(SLOT)?.split_once('.')?;
let (kind, n): (u8, usize) = (kind.parse().ok()?, n.parse().ok()?);
app.session.prefs.perspective_presets.iter().filter(|p| p.kind == kind).nth(n.checked_sub(1)?).map(|p| p.name.clone())
}
/// How many saved presets the slots list (the native menu is rebuilt when this changes).
pub fn listed_slots(app: &VectorcraftApp) -> usize {
(1..=3u8).map(|k| app.session.prefs.perspective_presets.iter().filter(|p| p.kind == k).count().min(SLOTS)).sum()
}
/// View → Perspective Grid → One/Two/Three Point Perspective (`kind` 1–3): the built-in views of
/// the type, then the saved presets' slots (`slots`, the type's ids).
pub fn menu(kind: u8, slots: [&'static str; SLOTS]) -> Vec<Item> {
let mut items: Vec<Item> = BUILTINS
.iter()
.filter(|b| b.1 == kind)
.enumerate()
.map(|(i, b)| {
// The normal view is the type's preset (`kind`), as before named presets.
let p = if i == 0 { json!({ "kind": kind }) } else { json!({ "name": b.0 }) };
Item::Cmd(b.0, "perspective.grid.preset", p)
})
.collect();
items.extend(slots.map(|id| Item::Cmd("User Preset", id, Value::Null)));
items
}
+5 -1
View File
@@ -38,5 +38,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)}),
),
};
run_and_close(app, id, params)
// On the active plane while the perspective grid shows, sized in plane units from the click.
match vectorcraft_engine::perspective_click(&app.session, id, &params, vectorcraft_geom::Point::new(x, y)) {
Some((id, params)) => run_and_close(app, &id, params),
None => run_and_close(app, id, params),
}
}
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more