Twelve lines in docs/target-app-parity.md quoted text from the
reference app's public pages: a masks tutorial, a blog post on
artboards, the default keyboard shortcuts, the Contextual Task Bar
announcement and the Brushes page. Each line now links the same page
and describes what it says in the doc's own words. Option, button and
tool names stay as they are, and so do VectorCraft's own labels and
status messages.
A filter plug-in gets 50 M instructions and a minute for each run.
Filters that order a plotter's paths, pack shapes or run a simulation
need minutes, and the app shouldn't freeze while they compute.
A filter's manifest may now ask for "budget": "heavy" (Budget): each run
gets HEAVY_FUEL (1 G instructions) and HEAVY_WALL_TIME_MS (300 s). Only a
filter may ask. plugin.install refuses such a filter without allowHeavy:
true, and plugin.inspect returns the entry a module would have without
installing it. Install Plug-in… and File › Open ask the user and install
on OK; the plug-in store keeps the answer, and folder loads skip heavy
filters with a note, since they ask nobody.
The app runs a heavy filter on a thread of its own (plugin_runs), not in
Background Save's queue, with the status bar and a progress window
(Cancel, Continue in Background) that it shares with Background Export
(export_progress::window). FilterRun splits plugin.run: prepared on the
session, run on any thread, its output handed in, and the next
plugin.run with the same params applies that output instead of running
the plug-in again, so the edit stays one guarded, journaled command. A
result whose document or plug-in changed meanwhile is dropped, and the
status bar says to run it again. A cancelled plug-in run is now
EngineError::Cancelled.
What a filter keeps with the document (#3720) goes with the run: it is
given _context.documentData when the run is prepared, and the
documentData of its output is applied with its objects, in the same undo
step, when the run is handed in (FilterRun::finish).
Tests: plugins/tests/abi.rs (100 M loop steps out of a light budget,
within a heavy one; folders skip heavy filters), manifest.rs, engine
tests_plugins.rs (consent, inspect, the store, hand-in, a handed-in
run's document data, cancel) and
ui-egui tests_plugin_runs.rs (installing asks, no Preview, a background
run as one undo step, a stale result kept out, the progress window).
CRAFT_UI_HEAVY_FILTER_WASM=<folder> writes a test filter to try in the
app. Its strings and messages have rows in each complete catalog
(COMPLETE_MESSAGES).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
With Windows Ink on, winit reports a pen in contact as a touch and
drops its barrel button, and egui-winit presses the primary button for
that touch. A tap with the barrel button held selected art instead of
opening the canvas context menu, and a drag with it held moved art or
drew with the active tool.
A message hook (winit's with_msg_hook) now notes each WM_POINTERDOWN
and whether its wParam has POINTER_MESSAGE_FLAG_SECONDBUTTON, which
Windows sets for a pen in contact with its barrel button pressed.
Before egui reads a frame's input, the primary press and release that
egui-winit made for such a contact become the secondary button, as
#3659 maps the barrel button on Linux. A canceled contact, or one
whose end never arrives, gets a secondary release. A touch with
the tip alone and finger touches are unchanged. photocraft's 8fc62f55
by animo fixed the same fault there.
Docs: docs/target-app-parity.md.
The Shape Builder lines of docs/target-app-parity.md repeated after
recent merges: the Tool Options line three times, one copy still saying
that Gap Detection and the stroke options are not built, and the stroke
split and Pick Color From Artwork lines twice, one artwork copy quoting
the reference app's combine page. One copy of each is left, in the
order Tool Options, stroke split, Pick Color From Artwork, marquee,
without the quotation.
The Pick Color From Artwork line in docs/target-app-parity.md quoted a
sentence from the reference app's Shape Builder combine page. The line
now links the page and describes the rule without quoting it.
each_paragraph_run skipped the empty paragraph after a closing line break, so a
selected object whose last paragraph's Auto Leading differed showed one Leading
instead of a mixed (blank) field. With no range, that paragraph now counts with
the style of the run before it. Covered by
leading_field_counts_the_final_empty_paragraph.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gCpjL3yUhaH3jRp7ksufh
Hyphenation... and Justification... already shipped upstream (#2310,
#2755). This ports the part of the archived fork PR #10 that upstream
lacked: a paragraph's Justification > Auto Leading now also sets
- the Leading the Character (and Properties) panel shows for Auto type,
blank where the selected paragraphs differ;
- the leading Alt+Up/Down (type.step) steps from, per paragraph (a run
spanning paragraphs is split so each takes its own);
- the lines Fill with Placeholder Text counts in area type.
ParaStyle::effective_leading mirrors the layout (exactly
CharStyle::effective_leading at the default 120%). Tests in doc, engine
and ui-egui; parity docs no longer list Hyphenation/Justification as
missing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gCpjL3yUhaH3jRp7ksufh
Pattern tiling and scatter cap each factor on its own: 20,000 tiles a
run, MAX_SUB subdivisions a segment, 20,000 copies a track. Those caps
are per track, and a path has as many tracks as it has subpaths, so
they multiply rather than bound.
The audit called the end-to-end impact unverified, so it was measured.
A pattern brush on parallel lines, each long enough to want more tiles
than a run allows:
1 subpath 20,000 nodes 320,000 anchors 36 ms
4 subpaths 80,000 nodes 1,280,000 anchors 142 ms
16 subpaths 320,000 nodes 5,120,000 anchors 571 ms
Exactly 20,000 nodes a subpath, linear with no ceiling. Scatter is the
same shape.
MAX_ANCHORS bounds the total for one brushed stroke, threaded through
both. It is 2,000,000, the figure the stacked-effects budget already
uses (effects::distort::MAX_COPY_ANCHORS, #3578), and bounded the same
way: per stroke rather than per document. One track at its own cap is
well inside it, so the budget bites only where the caps were
multiplying; sixteen subpaths now stop at the budget in 113 ms.
A piece is taken whole or not at all, so art is never half-drawn. The
walk that counts an art tile's anchors is iterative, and a tree it
cannot finish within MAX_WALK nodes is charged the whole budget rather
than undercounted, so nesting buys no room.
The per-factor caps are untouched: the issue asks for a total, and
changing both at once would hide which one did the bounding.
A Shape Builder drag merged only the regions the pointer passed over,
and the tool did not read Shift. The reference app's Shape Builder page
describes a Shift-drag that draws a rectangular marquee to merge many
paths at once.
With Shift held at the press, the drag now draws a rectangular marquee
from where it started to the pointer. The regions it reaches are shaded
while it is drawn and merge into one shape on release; with Alt also
held at the press, they are deleted. The marquee reaches each region
whose outline crosses it or has an anchor inside it, and the region that
holds the whole marquee. A Shift-click that does not move is a click,
and a drag without Shift works as before. With In Merge Mode, Clicking
Stroke Splits the Path on, a Shift-click on a stroke splits it as a
click does; a marquee splits no stroke, and while it is drawn the
pointer does not show scissors and no stroke is highlighted.
shapeBuilder.merge takes the marquee as rect: [x, y, width, height],
where x and y are where the drag started, and points may then be left
out. For Pick Color From Artwork, the corner where the drag started
counts as the drag's first point, and the opposite corner counts as its
last. With rect, splitStroke does not split a stroke.
Docs: docs/mcp.md, docs/target-app-parity.md.
Cause: with Snap to Point, the point of a moved selection that snaps to a
ruler guide is the grabbed point (#1534). Away from an anchor or center
that was the raw pressed spot (select.rs: `grab` fell back to `p`;
direct.rs: a straight segment's `grab: p`, MoveObject's `start`). A press
on a stroke or within the pick tolerance of an edge lies a few pixels
beside the path, so the pressed spot landed on the guide, "align" showed,
and the path stopped that far beside it: 0.096 pt at 3,891% and 0.0099 pt
at 28,757% in the #3701 screenshots, visible only when zoomed in.
Change: `guides::hold_on_path` finds the nearest point of the path (on a
straight segment exactly on its line). `select::hold_point` uses it for a
press on the stroke or outline, or within 6 px of it. The Selection tool
(`State::Moving::hold`), Direct and Group Selection moves
(`State::MoveObject::hold`) and Direct Selection's straight-segment drag
(`grab`) snap that point. The move delta still runs from the press, so
nothing jumps. A press in the middle of a fill still snaps the pressed
spot, as Snap to Point is documented to.
Verification: tests_prefs::a_move_held_by_a_paths_stroke_lands_the_path_on_a_ruler_guide
and tests_prefs::a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide
fail without the change (edge 0.0257 pt and anchors -0.0052 pt off the
guide) and pass with it (|gap| < 1e-9 pt). A headless egui/wgpu render at
3,891% and 28,757% puts the guide, the selection outline and the centre of
the 1 pt stroke band in the same pixel column. cargo xtask ci: all 10
steps passed; 9429 tests passed, 0 failed.
Review follow-up (CodeRabbit): a text node's characters carry their own fill
(CharStyle), saved apart from the node's appearance, so conform also checks
the runs of the label. dimension_colours_follow_the_document_colour_model
opens a CMYK document whose label characters alone are RGB; without the
change they stay RGB.
Review follow-up (CodeRabbit): leaving a dashed brush stroke out of the
paint count still let a fill plus that stroke count as one paint, so the
fill and each brush piece took the object opacity separately and the fill
showed through the brush art. A visible dashed brush stroke now forces the
layered path, like a non-solid paint does, and counts normally otherwise.
brush_fx::tests::object_opacity_fades_a_brushed_stroke_once compares a
brushed rectangle at object opacity 0.5 with the same rectangle in a group
at 0.5, with and without a dash and a fill; before the change the fill
plus dashed brush case had 1440 differing pixels.
A filter plug-in had nowhere to keep settings that belong to the whole
document rather than to one object, such as the pens a plotter holds or
a machine profile: it could only ask for them again on every run.
- A filter's output may hold documentData: any JSON of at most 1 MiB
(objects::MAX_DOCUMENT_DATA) that replaces what the plug-in keeps with
the document, null removing it, in the same undo step as its objects
(objects::decode_output). Too much refuses the output and the
document stays as it was.
- A filter gets it back in _context.documentData (null until it keeps
something). A filter reads and writes only its own.
- It is the document's foreign data, Document::unknown["pluginData"] by
plug-in id (vectorcraft_doc::plugin_data), where the perspective grid
and the brushes keep theirs. So the native file needs no section of
its own for it, and hosts from before keep it when they save. An
exporter is given the whole document, so it can read every
plug-in's. Nothing else lists it, as the reference app's Document
Info lists no plug-in data.
A live effect isn't given it: its geometry is worked out from the
object alone wherever it is drawn.
Tests: plugin_data.rs (each plug-in its own entry, forgetting what isn't
kept copies nothing, a damaged value holds nothing), objects/tests.rs
(kept, forgotten, refused over the limit), tests_plugins.rs (one undo
step with the objects, read back, not given to another filter, kept by
the native file among its foreign data, refused over the limit with the
document unchanged, null removes it) and tests/plugin_export.rs (an
exporter finds another plug-in's). The size error has its row in each complete
catalog (COMPLETE_MESSAGES).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
The planar map found each edge's source input by scanning every input
segment, and the face around each hole or merge point by scanning every
face, so it was quadratic in the segments; the Shape Builder therefore
gave up past 4096 segments (traced or type outlines) and offered whole
shapes. Index outline segments and faces by bounds on both axes
(RectIndex), walk an edge's segments lazily when finding its leaving
angle, and raise SHAPE_BUILDER_MAX_SEGMENTS to 65,536. Divide with open
paths shares the cap; its cap test now follows the constant.
Tests: a 20,000-segment blob cut by two lines, a 300-line hatch, the
index against a brute-force scan, and the existing cap test at the new
cap. Progress docs updated (G11, parity evidence, roadmap).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gCpjL3yUhaH3jRp7ksufh