Commit Graph
64 Commits
Author SHA1 Message Date
bflatastic 61ef472e71 object.setBounds: one helper for the asked width and height 2026-10-14 08:17:14 +07:00
Marco Arnulfo 4ef0cb76e6 Transform panel: X/Y/W/H edit the anchors selected with Direct Selection (fixes #1824) 2026-10-14 08:16:26 +07:00
dotCooCoo d9bd9b134d Symbols: instances of imported symbols are measured by their art
Every symbol instance was measured as its transform applied to the
20 × 20 square around the origin, which symbols made in the app keep
their art in. The SVG and DXF importers keep symbol art as drawn, so
the box of an instance of an imported symbol did not match its art.
The SVG Star instance at x 20, y 30 draws from (20, 30) to (40, 50)
and was measured from (10, 20) to (30, 40). Align moved such an
instance by the wrong amount, clicks and marquees beside its art
selected it, and the canvas, exports and prints left it out when its
art reached the view or the page and the square did not.

An instance is now measured by its symbol's art, which
Document::symbol_boxes looks up in the document each time
(Node::geometric_bounds_with, visual_bounds_with and
reach_bounds_with). With Use Preview Bounds on, the strokes in the art
count at their own weight, as the canvas draws them, and
object.setBounds counts them at that weight when it sizes an instance
or an object that holds one. When strokes do not scale and a width or
height of 0 or more is smaller than what a path's stroke adds,
object.setBounds now gives the path no width or height, as it does
for an instance whose art has strokes; before, it mirrored the path.
With Use Preview Bounds on, object.setBounds now measures the margins
that strokes add object by object, the members of groups and the
objects in symbol instances' art included, and solves for the width,
height and reference point asked for when the width and height are 0
or more. Before, it measured the margins of the whole selection and
could miss the size or the reference point asked for when strokes did
not scale and the objects' strokes added different margins, and when
strokes scaled and the width and height were out of proportion, even
for one path. A clip group, a Shaper Group, a compound path, type, and
a group or instance with effects or an opacity mask of its own are each
measured as one object, and so is an instance in perspective or one
turned or slanted relative to the bounding box, other than by quarter
turns. When the selection holds more than 4,096 objects to measure this
way, each selected object is measured as one. Instances inside symbol
art are measured by their own symbol's art. An instance is measured as
the square when its symbol is missing, when its symbol or a symbol
nested in its art holds an instance of itself, and when symbols nest 32
deep in its art.

Align and Distribute, the Transform panel, the bounding box and the
selection outline, clicks, the marquee, document.inspect and
document.node, Fit to Artwork Bounds and Fit to Selected Art, canvas
culling, thumbnails, the SVG filter region of an instance with a
raster effect, raster effect images, Export Selection, the SVG of an
artboard, printing, and the PDF, EPS, EMF/WMF and DXF writers use the
new box. The renderer also culls an instance by the effects and
brushed strokes in its art, and it culls a blend, envelope or repeat
by the brush art of what it holds and the effects in the art of the
instances in it. It culls an instance with geometry effects of its own
by the effects in its art after those geometry effects reshape the art.
It measures the effects in a symbol's art once for each symbol and
linear part of the transform that places it. For symbols whose art
holds instances, it measures at most 4,096 placements a frame, and at
most 4,096 more for each pattern tile it rasterizes and each placed
document it draws. Past that, it estimates the reach from the symbol's
reach with no transform, which it measures once. When the drawn
document's symbols change, the renderer drops the cached bounds of the
instances and of the objects that hold them.

The PDF and EPS writers, the SVG of an artboard and Skip Blank
Artboards grow the box of an instance whose art holds brushed strokes
by 16 times the widest stroke (at least 1 pt) of the objects in that
art that have a brushed stroke, plus 8 pt, as the renderer grows the
cull bounds of a brushed path. They keep an instance whose brush art
reaches the page. The SVG of an artboard also keeps an object that
holds an instance when what the canvas paints for it, the effects in
the symbol's art and the instance's own included, reaches the artboard.

Not yet: other commands that measure objects, such as Select › All on
Active Artboard, Transform Each and Convert to Artboards, still measure
an instance by the square.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
bflatastic 359d9bb833 Distribute spaces the selected anchor points too, and its buttons work with the points of one path (#1636) 2026-10-14 02:09:06 +07:00
bflatastic a046fd30fe Make Compound Path: the compound path of a group takes its backmost path's paint
A group has no paint of its own, so a compound path made of one (outlined type) had none
and drew nothing until a fill was set. The paint now comes from the backmost path, or
from the compound path that painted it.
2026-10-13 06:34:12 +07:00
bflatastic 3acc29f8e0 Fix #1442: an unknown parameter key is an error naming it and the nearest accepted key 2026-10-13 02:54:33 +07:00
bflatastic 897d99a70f Fix #1448, #1460: Transform Objects in the Transform dialogs and Transform Each, and Reflect across an angle 2026-10-13 02:54:08 +07:00
dotCooCoo 571289ddb6 Part of #1429: Alt-drags with the selection and transform tools copy the objects once per drag
On each pointer move of an Alt-drag with the Selection, Direct
Selection, Group Selection, Rotate, Scale, Reflect or Shear tool,
Session::preview put the drag's snapshot back and ran object.transform
with copy again, and duplicate_in copied every selected object again.
The Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview
on copied every selected object again on each change of a value.

The interaction now keeps the untransformed copies that its first
preview with copy makes of the snapshot. Each later preview that
copies the same objects of the same snapshot transforms only those
copies. The copies keep their ids, and the document, the selection,
the undo step and the journal are the same as before. With 25,000
rectangles selected, a release build of vectorcraft-cli took a median
of 138.5 ms for each Alt-drag move after the first preview and takes
105.7 ms with this change.

Docs: docs/target-app-parity.md.
2026-10-13 02:21:35 +07:00
dotCooCoo ffa948dcc3 Part of #1429: Edit and Object commands on many objects take time linear in the document
Delete, Cut, Group, Make Compound Path, Make Clipping Mask, Send to
Current Layer, Ungroup and Release Compound Path removed, inserted or
moved one object at a time. duplicate_in inserted one copy at a time;
Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs,
Transform Again after a copy, Transform Each with Copy, the perspective
copies and each move of an Alt-drag call it. Each insertion or removal
moved the later objects of the same layer or group to other indices.
The next lookup by id then walked the tree, and after every fourth such
walk Document::index_path rebuilt the whole lookup cache. Each command
did work that grew with the number of objects times the size of the
document: with 25,000 rectangles selected, a release build of
vectorcraft-cli took 7.2 s to group them and 11.5 s to copy them with
object.transform.

Document::remove_many, Document::wrap and Document::insert_beside
remove nodes, wrap them in a new node, or put nodes directly above or
in place of their anchors, in one pass over the children of each
parent. Each finds the nodes with one index_paths call, checks every id
before it changes anything, and clears the lookup cache once. The
commands above use them. The objects keep their order, the copies take
their ids in the same order, and the selection, the undo step and the
journal are the same. duplicate_in still copies one root at a time when
a root is listed twice or lies inside another root. A release build
with this change groups the 25,000 rectangles in 27 ms and copies them
in 121 ms.

Docs: docs/target-app-parity.md.
2026-10-12 23:42:43 +07:00
bflatastic a41d59604b Fix #1408: the Transform tools and commands act on the anchors selected with Direct Selection 2026-10-12 21:01:15 +07:00
bflatastic ec38b00a22 object.isolate: an id that doesn't exist is reported as such 2026-10-12 20:06:04 +07:00
Ayberk A. 3073f0ab03 Isolation mode: double-clicking a single object isolates it
With Double Click To Isolate on, the Selection tool isolated groups only. A
double-click on a single path, compound path, image, mesh or placed file now
isolates it too, as the public documentation describes. Type still switches to
the Type tool; blends, envelopes, repeats and symbol instances keep their own
editing modes.

While a single object is isolated only it is hit, marqueed and taken by Select
All, new art goes beside it in its group or layer, and a double-click on the
empty canvas leaves with it selected. object.isolate accepts any object except
type.
2026-10-12 18:56:41 +07:00
dotCooCoo ae061eacfb Part of #394: art moved whole with direct-selected anchors follows Transform Pattern Tiles
With anchors of one path direct-selected and another object selected
whole, the arrow keys (object.nudge), path.moveAnchors and a Direct
Selection drag of the selected anchors moved that object without its
pattern tiles, whatever General › Transform Pattern Tiles was set to.
When the same object was selected on its own, the arrow keys and a
Direct Selection drag followed the preference. path.moveAnchors left
the tiles in place in that case too.

path.moveAnchors now follows the preference for the objects it moves
whole, as the transforms, Align and Distribute do. path.moveAnchors
and object.nudge also take a `patterns` param for one call. When those
objects use a pattern, the journal records the choice, and a recorded
action plays back with it. Partly selected paths still move only their
selected anchors.

Docs: docs/mcp.md (transformPatternTiles), docs/target-app-parity.md.
2026-10-12 14:50:00 +07:00
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00
dotCooCoo fd2ab03cd9 Layers: Send to Current Layer refuses a hidden or locked current layer
A click on a Layers row runs layer.setCurrent, which makes the row's
layer current whether it is hidden or locked and keeps the selection.
Object › Arrange › Send to Current Layer then moved the selected
objects into that layer, or into a sublayer of a hidden or locked
layer, and left them selected. In that case drawing, typing and
pasting put new art on the topmost layer that is shown and unlocked,
and since #1039 they fail with "the target layer is locked or hidden"
when no layer is shown and unlocked.

Send to Current Layer now fails with that error when the current layer
is hidden or locked, or is inside a hidden or locked layer. It makes
the check that DocState::target_parent makes for new art
(Document::is_editable) before it moves anything or records an undo
step. The objects stay where they are and stay selected. The message
already has a row in the cs, de, es, fr, it, ja, pt-br, ru and uk
catalogs.

Docs: docs/target-app-parity.md.
2026-10-12 08:16:24 +07:00
dotCooCoo ac8d7ebce8 Transform: Transform Again repeats the options of the transform it repeats
Transform Again kept only the last transform's matrix and its Copy
flag. After a Selection tool handle drag resized area type, Transform
Again scaled the type instead of resizing the type area again. After
Move, Rotate, Scale, Reflect or Shear with Transform Patterns set
differently from General › Transform Pattern Tiles, Transform Again
followed the preference, and after a scale whose strokes or corners
param differed from Scale Strokes & Effects or Scale Corners, it
followed those preferences. A recorded Transform Again played back
with the preferences in effect at playback, although its step records
the strokes, corners and patterns values it used.

last_transform now keeps the options with the matrix, as
last_perspective keeps the perspective.transform params.
apply_transform stores the copy, typeAreas and patterns values a
transform command used, and strokes and corners when it scales.
commit_interaction stores the params of a drag's last object.transform
preview with the values that preview noted. object.transformAgain
passes the options to the transform it repeats, and a true or false
value given in its own params under one of those names takes
precedence. A recorded step plays back with the values it records.

Three engine tests fail without the change: Transform Again after a
handle drag on area type, Transform Again after a Move with patterns:
false while Transform Pattern Tiles is on and after a Scale with
strokes: true while Scale Strokes & Effects is off, and a recorded
Transform Again played back with Transform Pattern Tiles on.

Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md.
2026-10-12 08:16:07 +07:00
Ikki Nishihara 4109717a9a fix: 動かないアンカー整列でUndo履歴を増やさない #1180 2026-10-12 07:25:48 +07:00
Ikki Nishihara fcb1d81ba6 fix: 複合パスの外観と塗り規則を修正する #1197 2026-10-12 00:12:52 +07:00
arhammusheerandClaude Opus 5.5 0724aafbb6 #1172: share an object's appearance with its undo history until it changes
Every edit of an object copied its whole appearance (its fills, strokes and effects, about
540 bytes for a basic object) into the new node, though a move leaves it as it is. Over 45
whole-selection moves of 10,000 paths, undo history grew by 589 MB; now 349 MB (41 % less), and
a nudge or a drag of the selection takes 12 ms instead of 15.

- Node::appearance is a vectorcraft_doc::Shared<Appearance>: copy-on-write. A clone shares it,
  and the first change through DerefMut copies it (Arc::make_mut). Reads, writes, equality (also
  against a plain Appearance) and the saved form are those of the Appearance itself, so call
  sites read and change it as before. Sites that build or assign a whole appearance add .into(),
  and the few that need a plain copy take (*n.appearance).clone().
- Node::transform changes the appearance only when it has gradients to transform, and pattern
  paints are transformed only when there are some: a move no longer copies an appearance it
  doesn't change.

The workspace suite (5,301 tests) and clippy pass with the change; the doc and engine suites
pass again after the transform guard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-11 23:27:21 +07:00
arhammusheerandClaude Opus 5.5 4b260c364d #1172: edit a large selection in time linear in the document, not quadratic
Every lookup by id walked the whole tree, and the edit path did one per selected id, so editing
a selection of n objects cost O(n²). Measured with the new `perf --deep` rows at 25,000 paths:

- Select All: 458 → 3.5 ms
- nudge the selection: 3,517 → 34 ms
- drag it with the Selection tool, per pointer move: 3,413 → 35 ms
- undo / redo: 407 → 3.1 ms

- vectorcraft_doc: Document::index_paths (every id's index path, in one walk; the first in tree
  order for a duplicated id, as index_path), node_at, node_mut_at (copy-on-write, as node_mut)
  and ancestry_at.
- apply_transform, roots_of, transform_patterns, doc_sane, Selection::prune and
  Document::paint_order look their ids up with one walk instead of one each.
- Selection::set dedups with a hash set instead of Vec::contains per id.
- vectorcraft-cli perf --deep: tracked rows for editing a whole selection (select, nudge, drag,
  undo, redo), live effects, every Pathfinder operation, typing in a long story, and memory.

tests_lookups checks the batched lookups against the one-id ones on nested trees with missing and
duplicated ids; storing the last duplicate instead of the first fails it. The doc and engine
suites (2,000 tests) pass unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-11 23:26:53 +07:00
bflatastic 06d3e52c24 #1180: Align aligns the selected anchor points
With anchor points selected by Direct Selection, the Align buttons moved
the whole path, so two points couldn't be lined up. As in Illustrator,
they now align the selected points: to the points' own bounds (Align To
Selection) or to the artboard. A path selected whole still aligns as an
object.
2026-10-11 22:08:01 +07:00
bflatastic 7f35522b15 #1197: Make Compound Path cuts holes where paths overlap
Make Compound Path kept each path's direction under the non-zero fill
rule, so a shape drawn inside or across another, running the same way,
left the overlap filled instead of making a hole. The paths in front of
the backmost one now run against it, so where they overlap it they cut
holes, as users expect from a compound path.
2026-10-11 21:32:23 +07:00
bflatasticandClaude Opus 5.5 9b12fff215 #1163: Release splits a path of several subpaths into one path each
A path of several subpaths (an SVG `d` with several `M`s, PDF paths, a
path drawn in one go over MCP) is a compound path in Illustrator, and
Object › Compound Path › Release splits it into separate paths. Here
Release only acted on compound path objects, so such a path couldn't be
broken apart. Release now gives each subpath a path of its own, with the
path's paint, in stacking order; the first keeps the path's id. One undo
step, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:58:13 +07:00
bflatasticandClaude Opus 5.5 fbc1c494b2 #1144: Live Corners round corners between curved segments too
A corner is now any anchor that isn't smooth where two sides meet at an
angle, straight or curved, as in Illustrator. The cut meets each side at
its setback measured along the curve, leaves both sides along their
tangents there, and each curved side is trimmed to what the cuts at its
ends leave of it. The largest radius is still the one taking the cut
halfway along the shorter side, measured along the curve. Corners
between straight sides draw exactly as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:14:08 +07:00
bflatasticandClaude Opus 5.5 cc6fd8a5dc Review: Align and Distribute share one apply_moves; ROADMAP (#1108)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 11:33:06 +07:00
dotCooCoo a31cdab606 Part of #394: Align and Distribute move pattern tiles when Transform Pattern Tiles is on
object.align, object.distribute and object.distributeSpacing moved
objects without their pattern tiles, whatever General › Transform
Pattern Tiles was set to. They now follow the preference as Move,
Nudge and the Selection tool do: with it on, pattern fills and strokes
move with the art. Each also takes a `patterns` param for one call, as
the transforms do. When the art uses a pattern, the journal records the
choice, and a recorded action plays back with it.

Docs: docs/mcp.md (transformPatternTiles), docs/target-app-parity.md.
2026-10-11 11:33:05 +07:00
bflatasticandClaude Opus 5.5 d10962ac45 Review: one Selection::deselect_uneditable for the hide and lock commands; ROADMAP (#1107)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 11:33:05 +07:00
dotCooCoo 975dea056c MCP: object.setProps removes the objects it hides or locks from the selection
object.setProps with visible: false or locked: true left the objects
it hid or locked selected, the key object included, and kept selected
objects inside a group or layer it hid or locked. Align then moved a
locked object, and aligned the other objects to a hidden key object.
object.lock and object.hide clear the selection, and layer.setProps,
which the Layers panel's eye and lock columns run, deselects what it
hides or locks.

After a call with visible: false or locked: true, object.setProps now
deselects every selected object that is then hidden or locked, as
layer.setProps does: the selected objects it hides or locks, selected
objects inside them, and selected objects that were hidden or locked
before the call. It clears the key object when the key is one of
them. Undo selects them again and restores the key object. A call
with visible: false or locked: true that changes nothing records no
undo step and still deselects them, as object.lock and object.hide
do. A call that sets neither, such as an opacity change from the
Control bar or the Transparency panel, leaves the selection as it is.

Docs: the object.setProps params text, docs/mcp.md, docs/target-app-parity.md.
2026-10-11 11:33:05 +07:00
bflatasticandClaude Opus 5.5 0a8d334510 Review: Align and Shear choice errors reuse the catalog's one-of message; ROADMAP (#1106)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 11:33:04 +07:00
dotCooCoo a2abe63e8d Align, Reflect and Shear: object.align needs a direction; unknown choices are errors
object.align with neither horizontal nor vertical returned success
and moved nothing. The command palette, and a key bound to it in
Keyboard Shortcuts, run it with no params. It now returns
"give horizontal or vertical", as object.distribute already did.
An action recorded while Align ran from the palette or a bound key
contains an object.align step with no params. Playing that action now
fails at that step, and command.batch rolls back the whole action.

Values outside a parameter's choices were read as another choice or
ignored. Align ignored an unknown horizontal or vertical and aligned
to the selection for an unknown to value. Distribute read an unknown
direction as center. Distribute Spacing and Shear read any axis other
than "vertical" as horizontal, and Reflect read any axis other than
"horizontal" or a number as vertical. A bounds or to value that was
not a string fell back to the default, and a spacing that was not a
number gave automatic spacing. The commands now return an error that
names such a value, before they move anything or record an undo
step. When a to, bounds, axis or spacing value is missing or null,
the command uses the parameter's default: Distribute Spacing without
an axis spaces the objects horizontally.

The run_command description and docs/mcp.md showed object.align with
an align param it does not have; they now use horizontal. The new
status-bar messages have rows in the de, es, fr, it, ja, ru and uk
catalogs.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-11 11:33:03 +07:00
dotCooCoo 8e4158b364 Align: Align to Artboard uses the active artboard
Align to Artboard aligned the selection to the artboard under its
center, or to the first artboard when no artboard was under the
center. A press with a selection tool inside an artboard makes that
artboard the active one (#693). With the Selection tool, a
Shift-click on an empty spot inside an artboard also makes it active
and keeps the selection. Align to Artboard did not use the active
artboard.

object.align takes artboard, a 0-based artboard index, and uses it
when its to param is "artboard". The app passes its active artboard,
the one the Artboards panel highlights, to each object.align it runs
that names none, as it does for Paste in Place, Paste in Front, Paste
in Back (#693) and Select › All on Active Artboard (#1006). MCP calls
in remote mode also get it. Without artboard (headless MCP and the
CLI), or with an index the document doesn't have, object.align aligns
to the artboard under the center of the selection, or to the first
artboard when no artboard is under the center.

command.batch takes artboard too, and the app passes it the active
artboard. The batch passes it on to each step of these five commands
that names none, when the step runs. When the document a step runs in
has no artboard at that index (for example, after an earlier step
deleted the active artboard when it was the last one), the step gets
that document's last artboard. A step in a document that an earlier
step opened, created or switched to gets the same index, not that
document's own active artboard.

With Align to Selection, one selected object aligned from the Align
panel, the Control bar or the Properties panel is aligned to the
active artboard too, unless it is the key object.

Recorded actions leave out the artboard that the app passes to these
commands and to the batch. The Actions panel plays an action as a
batch. Its Align to Artboard, All on Active Artboard and in-place
paste steps that name no artboard use the artboard that is active
when it plays, and the default Center on Artboard action now centers
on the active artboard. In-place paste steps recorded since #693 and
before this change still paste onto the artboard that was active when
they were recorded.

Tests cover object.align with and without artboard on three artboards;
a Shift-click that makes an artboard active, after which Align to
Artboard aligns to that artboard but keeps an artboard the call names;
a batch that passes its artboard to each step when the step runs,
after a step deletes the last artboard and in a new document; recorded
actions that leave the artboard out; and recorded Align to Artboard
and Paste in Place steps and the default Center on Artboard action,
which play on the artboard that is active when they play.

Docs: docs/mcp.md (Align to Artboard), docs/target-app-parity.md.
2026-10-11 11:33:01 +07:00
dotCooCoo 1819921e03 Align: a compound path or selected group that contains the key object stays in place
Align and Distribute Spacing move a selected compound path or group as
one object, and they found the key object only when its own id was
among the objects they move. When the key was inside a compound path
or a selected group, aligning to the key moved that compound path or
group like any other object, and the key moved with it. In that case,
Distribute Spacing with a spacing held the first object still instead
of the key.

The key object can end up inside a selected group or a compound path.
Dragging its row into a group in the Layers panel (layer.move) keeps
the selection and the key. A Selection-tool Shift-click on that group,
when it is unselected and directly in a layer, then adds it to the
selection (select.toggle) and keeps the key. Other ways of adding the
group keep the key as well: a Shift-click in the Layers panel's
selection column or on the group's target circle, a Group Selection
click that hits the key itself, select.add, and select.anchors or
select.anchorsMany in add or toggle mode. In isolation mode, a
Selection-tool click on one of several selected objects in the
isolated group makes that object the key. In an isolated compound
path, the same click on the outline of a selected member makes that
member the key.

Align to the key object and Distribute Spacing with a spacing now keep
in place the selected object that is the key or contains it. Both
commands look for that object among the key and its ancestors, so a
group around the key's group also stays in place. Aligning to the key
still lines the others up with the key's own bounds.

Docs: docs/target-app-parity.md.
2026-10-11 07:18:28 +07:00
dotCooCoo dc6e8a4253 Object: Align, Move, Arrange and other commands that change nothing record no undo step
These Object commands edited the document even when nothing changed, so
the call recorded an undo step, emptied Redo and marked the document
modified:

- object.align, object.distributeSpacing and object.distribute (three
  or more objects) when no object moves. With one object selected,
  Object › Align aligns it to its own bounds, so it recorded a step
  every time. Calls on objects that were already aligned or evenly
  spaced also recorded a step, including objects that an earlier Align
  or Distribute left a rounding error away from the position the next
  call computes.
- Move, Rotate, Scale, Shear, Transform and Nudge by an identity
  transform without a copy, Transform Again after one, Set Bounds with
  the current position and size, and a Selection tool drag that ends
  where it started.
- Nudge by 0 with anchors direct-selected or with only ruler guides
  selected, and Transform with nothing selected or with ids that name
  no object, with or without a copy.
- Bring to Front and Bring Forward on objects already in front, Send to
  Back and Send Backward on objects already at the back, and Send to
  Current Layer on objects already on top of the current layer.
- Ungroup, Release Compound Path and Release Clipping Mask with none of
  their objects selected, and Expand Shape with no live shape selected.
- Lock with every selected object already locked, Hide with every one
  already hidden, Unlock All with nothing locked and Show All with
  nothing hidden.
- object.setProps with the values the objects already have, or with no
  object.
- object.setLiveShape with the values the shape already has: a
  polygon's sides, angle, radius or equal sides, or a side length that
  gives exactly its current radius; an ellipse's pie, including Invert
  Pie on a whole ellipse; and a corner radius or kind that the corners
  already have.

These commands now return without editing the document, and Align and
Distribute count a move of 1e-9 pt or less on both axes as none. Such a
call records no undo step, keeps the step to redo and leaves the
document unmodified. In these cases Ungroup no longer resets the
selection, and Release Compound Path, Unlock All and Show All no longer
empty it. A Transform with a copy of no object keeps the selection.
Lock and Hide still deselect the objects. A Nudge by 0 no longer
expands a live shape whose anchors are direct-selected. The replies
have the same form as before. An identity transform still sets what
Transform Again repeats. The journal still records each call, so the
Actions panel records it as before.

When every value given to Set Bounds is the current one, it now applies
the identity transform instead of the transform computed from those
values, which can differ from the identity by a rounding error on a
rotated object or with Use Preview Bounds.

When a drag comes back to its start, the identity preview no longer
edits the document. Session::preview now advances the revision when it
undoes the previous preview, so views redraw that drag.

The undo and redo row of docs/mcp.md now notes that many calls that
change nothing record no undo step, so undo after one undoes the step
before it.
2026-10-11 07:02:28 +07:00
bflatasticandClaude Opus 5.5 d57134a308 Fix #938: Live Corner widgets on every selected path
The corner widgets only showed when the selection was exactly one path.
Now every selected path that has them shows its widgets: live rectangles
and polygons with the Selection tool, any path with Direct Selection,
each with its own corners (all of a path selected whole, the
Direct-Selected ones otherwise). Dragging any widget rounds every shown
corner of every path in one step, Alt-click cycles their kind, and the
red limit outline covers them all; a double-click opens Corners for the
path it was on. Each widget is drawn in its own layer's colour.

object.setLiveShape takes items: [{id, corners?}], several objects each
with its own corners, which the drag sends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:19:52 +07:00
bflatasticandClaude Opus 5.5 7c0ce8a382 Part of #812: polygon properties in the Properties and Transform panels
A selected live polygon now shows Illustrator's Polygon Properties in the
Transform panel and the Properties panel's Transform section: side count,
angle, corner type and radius, radius, side length and Make Sides Equal
(enabled once an uneven scale has made its sides differ). Before, only the
side count and corner radius were there.

object.setLiveShape takes polygonAngle (degrees counterclockwise),
polygonRadius and sideLength (points, centre to vertex / side) and
makeSidesEqual, which drops the uneven scale and shear and keeps the
polygon's centre, angle and mean radius. Each call is one undo step; a
radius or length that isn't a number above 0 is an error. A star stays a
plain path, as in Illustrator.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 13:00:21 +07:00
bflatasticandClaude Opus 5.5 78f0297127 Part of #812: an ellipse's Pie Start and End Angle and Invert Pie
An ellipse kept pie angles in its model, but nothing drew or set them. Now
the Transform and Properties panels show Ellipse Properties with Pie Start
Angle, Pie End Angle and Invert Pie, as in Illustrator, and
object.setLiveShape takes {pieStart, pieEnd, invertPie}. The pie runs
counterclockwise on screen from the start angle (0 at 3 o'clock) to the
end angle and closes through the centre; 0 to 360 is the whole ellipse,
an end of 0 means 360, and Invert Pie swaps the angles to show the other
part. One undo step each; a bad angle is an error. The labels are
translated in every complete language. The on-canvas pie widget is still
to come.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 03:00:15 +07:00
David Whiting 7e82dbc05b M2.9: add editable Shaper groups and shape orientation steps 2026-10-08 22:52:23 +01:00
bflatasticandClaude Opus 5.5 2a35c4a82f Fix #522: SVG round trips keep hidden objects and data-* attributes
An SVG used as an animation rig lost its hidden alternates and their
rotation pivots: the exporter left hidden objects out (only hidden layers
could be kept), and the importer dropped data-* attributes.

- SVG export: hiddenLayers (on by default for document.save, as before)
  now keeps hidden objects too, each written display="none" on its own
  element (path, image, text or group). Exports still leave them out
  unless asked; the CLI's convert asks, as a conversion keeps the
  document.
- An object's own data: ObjectAttributes.data, the data-* attributes in
  order (ours, data-name and data-vc-*, aside). The importer keeps them on
  the objects and layers they belong to (at most 64 per element), the
  exporter writes them back, the native format saves them (missing in
  older files), and object.setProps {data: {key: value|null}} sets or
  removes them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 03:36:08 +07:00
bflatasticandClaude Opus 5.5 f31d6874aa Fix #541: a click on a selected object makes it the key object
With the Selection tool, a click (no drag, no modifier) on one object of a
selection of several makes it the key object (select.key); a click on the
key again lets it go. The key object is drawn with a thicker outline,
Align aligns to it (the Align panel shows Align to Key Object while there
is a key, and choosing Selection or Artboard lets it go), and
object.align without `to` aligns to the key when there is one, so the
Control bar's and the Properties panel's Align buttons do too. With a
spacing, Distribute Spacing keeps the key object still and spaces the
others from it, and it reports too few objects instead of indexing an
empty list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 02:08:56 +07:00
bflatasticandClaude Opus 5.5 1d14f337db Fix #511: Live Corners on any path's corners (stars, polygons, pen paths)
Corner widgets only ever showed on live rectangles: CornerWidgets::of bailed
out unless the selection's live shape was LiveShape::Rectangle, and
object.setLiveShape only edited rectangle radii. A star is a plain path, so it
had no widgets with either selection tool.

- geom::corners: the corners of any path (anchors without handles between two
  straight sides) and the path with them cut round, inverted round or
  chamfered, each corner a circle tangent to both sides, its radius no larger
  than takes the cut halfway along its shorter side. Rectangles now cut their
  corners with it (bit-identical anchors and layout).
- doc::corners::LiveCorners/set_corners: one model for a live rectangle's,
  a live polygon's (new radii/kinds) and any other path's corners (new
  LiveShape::Path keeping the uncut outline, plain again once nothing is cut);
  radii scale with transforms (Scale Corners) and stay circular.
- object.setLiveShape takes anchor indices for `corners` on any path; polygons
  keep a shared radius as their sides change.
- Widgets: Direct Selection on any path, Selection on live rectangles and
  polygons; placed on each corner's bisector, the drag follows it, the red
  limit outline marks corners at their own limit; the angle preference and
  View > Hide Corner Widget apply. Corners dialog and the Properties and
  Transform panels' Corner Radius (now on polygons too) work on them.
- Tests for the star, the polygon and a pen path in geom, doc, tools, engine
  and ui-egui; docs and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 23:05:41 +07:00
bflatasticandClaude Opus 5.5 44c7e64e1b Fix #442: alike circular corners on rectangles that aren't square
Live rectangles drew elliptical corners after an uneven scale in v0.4.0;
inconsistent on a rectangle that isn't square:

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 14:34:37 +07:00
bflatasticandClaude Opus 5.5 8e3dbd5369 Part of #394: Preferences for snapping, handles, rubber bands, pattern tiles, tints and type
Fourteen more Preferences options that were stored but read by nothing now
act, for the mouse and for agents (prefs.set, then pointer_gesture,
press_key or the commands):

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 09:43:12 +07:00
bflatasticandClaude Opus 5.5 2f710d3acc Fix #414: select, move and delete ruler guides with the selection tools
Guides dragged out of the rulers could not be touched again: no tool hit-tested
them, so a click on a guide went to the art under it.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 08:55:51 +07:00
bflatasticandClaude Opus 5.5 b05c14f8eb Fix #381: round single corners with Direct Selection (per-corner Live Corners)
A live rectangle stored four corner radii but drew them all with the largest,
and the corner widgets always rounded all four corners, so one corner could not
be rounded on its own.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 13:02:55 +07:00
bflatasticandClaude Opus 5.5 4bc0837867 Fix #221: Effect > Pathfinder works on several selected objects
Choosing an Effect > Pathfinder item (Add, Intersect, Exclude, Subtract...)
with several loose objects selected now groups them and applies the live
effect to the group, in one undo step, so the result shows at once and stays
editable. A selection that already holds a group, or a single object, works
as before, and agents get `grouped: true` back when it happens.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 02:21:06 +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 <[email protected]>
2026-10-06 12:10:00 +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 <[email protected]>
2026-10-06 11:00:15 +07:00
bflatasticandClaude Opus 5.5 96604acffb Fix #39: rotated objects keep their angle and a rotated bounding box
Rotating an object baked the rotation into its geometry and kept nothing
else, so the bounding box, the handles and the Transform/Properties
panels fell back to the page-aligned box (the Rotate field went back to 0).

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 06:28:59 +07:00