Commit Graph
32 Commits
Author SHA1 Message Date
bflatastic 1498c0ea52 Merge branch 'pr-513' into batch/t5 2026-10-16 01:35:39 +07:00
bflatastic 4812b3ebfa Merge #317: M1: drop shadows under a background blur (#255) (@Barzabel) 2026-10-15 19:55:05 +07:00
bflatastic 4dc7c1b59c #317: compute the backdrop once under a casting layer
With drop shadows the backdrop was read twice: before the content and
again after the shadows. The first read is only what backdrop effects
inside the content see, so it now runs only when the layer has such
descendants, and the content is copied for the shadow caster only when it
gets a layer blur, noise or texture. The output is unchanged.
2026-10-15 19:25:05 +07:00
NetterSilverandClaude Haiku 5.5 b3b6b14eb4 Anti-alias rectangular frame clips on fractional device edges
Fixes #428

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:18:59 +02:00
Barzabel 698e249fbf M1: drop shadows under a background blur (#255)
A layer with a background blur (or glass) drew its blurred backdrop into
its own pixels before casting its drop shadows from them, so a translucent
fill cast a shadow as dark as an opaque one, and with "show behind
transparent areas" the shadow was cast from the backdrop's pixels. The
backdrop was also read before the shadows were drawn, so a shadow showing
behind was never seen through the blur.

When a layer has both, it now casts its shadows from what it paints (with
the layer blur, noise and texture it will get), draws them, and only then
reads the backdrop, which goes under its content; the content still sees
the first backdrop under it while it's drawn. Layers without both draw as
before.
2026-10-14 18:45:15 +03:00
fede9234 54ba84eddc Text: colour emoji draw from their own colour tables (#268)
Colour emoji came out as the placeholder rectangles their outlines carry
(Apple Color Emoji) or as .notdef when the face has no outline at all:
nothing read sbix, CBDT/EBDT or COLR. A glyph now asks its face for
colour ink before its outline: an embedded bitmap strike decoded to
premultiplied RGBA and placed by the strike's bearings the way Skia (and
CoreText) places it, scaled to the size the run asks for, or a COLR v0
glyph's layers filled from the font's palette.

Faces that carry only colour tables are indexed again: Apple Color Emoji
is no longer skipped for being 190 MB (the scan still reads headers and
name tables only), and "Segoe UI Emoji" and "Noto Color Emoji" join
"Apple Color Emoji" as colour fallbacks, read only for a text that
nothing else covers. The png crate decodes the strikes; skrifa keeps
shaping, metrics and outlines.

Left for later: COLR v1 paint graphs, alpha-only strikes and the SVG and
PDF exports (text is outlines there).

Tests: 8 cases in the text crate (placement, PNG and raw-BGRA decoding,
scaling, COLR layers and bitmaps of the machine's own colour font) and a
render case gated on that font; the font-free ones run everywhere.
Verified on Windows with Segoe UI Emoji (COLR) and with Noto Color Emoji
(CBDT) read from a downloaded file; the sbix path runs through the same
code and needs a macOS run to be seen.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-14 16:19:46 +02:00
6d735653a2 fix(render): keep one outline depth budget through nested booleans (#193) (#231)
Boolean operands restarted subtree_outline at depth 0, so booleans nested
in booleans bypassed the 64-level limit and could overflow the stack.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:30:29 +07:00
YTwsyandbflatastic 33dcb1aef6 Refuse incomplete SVG/PDF exports beyond the depth limit (#218)
SVG and PDF exports check the visible subtree's depth before bounds or drawing and refuse, with an actionable message, a document nested past 256 levels instead of silently leaving the deep layers out; nothing partial is written, and an inner layer can still be exported. The CLI and MCP report the same error. Fixes #208.

Also: on Windows, figcraft and figcraft-cli link with an 8 MB main-thread stack (Windows gives 1 MB where Linux and macOS give 8), so an export at the depth limit no longer overflows it.

Co-authored-by: bflatastic <bflatastic@users.noreply.github.com>
2026-10-14 05:23:28 +07:00
bflatastic d7f76246d3 M3: effects on groups: inner shadows and background blur 2026-10-13 13:36:54 +07:00
bflatastic 485715f11e M1: cursors per tool and modifier, panel widths, outline options, dark pages
- Cursors: the engine names a cursor for every tool, modifier and hover
  target (rotate turned to its corner, pen states, eyedropper, comment,
  copy on Alt, not-allowed while an instance refuses the edit); the UI shows
  the system's or draws its own; guides and handles keep their cursors;
  ui.inspect reports it.
- Panels: each edge drags its panel within limits, a double-click resets,
  widths are per window and kept in the preferences (view.panelWidths); a
  small window keeps its canvas.
- Outline mode: Include hidden layers and Include object bounds
  (view.outlinesHiddenLayers, view.outlinesObjectBounds), drawn and
  clickable; light lines on a dark page; check marks in the View menu.
- A file or page made in the dark theme has a #1E1E1E page (sessions
  without an interface keep #F5F5F5); text drawn on a dark page is white.
- Tests for Enter and Escape on a selected text.
2026-10-13 00:29:41 +07:00
bflatastic a5a706674c M4: vector multi-edit and edit tail
Vector edit mode can now edit several vector layers at once, and the rest of the vector edit tail
from the public documentation landed. A behaviour suite came first
(`crates/engine/src/vector_multi_behaviour_tests.rs`, 5 behaviour tests with about 30 table rows and
steps, plus a junk-parameter sweep of every `vector.*` command run in multi-edit mode).

- **Multi-edit.** Enter, a double-click or `vector.edit.begin {ids}` with several shapes selected
  puts them all in edit mode, converting them in one undo step. The clicked or first one is the
  active layer. Pressing another layer's point, handle or segment makes that layer active. Shift-click,
  marquee and lasso select points in any layer. Dragging a selected point, the arrows, Delete,
  delete and heal, mirroring, smoothing, corner radius and the new cap and join commands reach every
  layer's selected points in one undo step. The pen, bend, paint bucket, cut, eraser, join and
  position work in the active layer. A click away from every edited layer leaves edit mode.
  `vector.edit.state` now reports `layers`, `others` and `handles`.
- **Several handles.** Shift-click Bezier handles to select more than one. Dragging one moves the
  others by the same amount. Also available as `vector.edit.select {handles}` and
  `vector.edit.moveHandles`. Selected handles are drawn filled.
- **Rounding next to curves.** A sharp point with a corner radius rounds even when one of its
  segments is curved. The curve is cut where the arc meets it and keeps the rest of its shape, and
  the arc meets each side smoothly (`VectorNetwork::rounded`).
- **A cap and a join per point.** `Vertex` gets optional `cap` and `join` values. When unset, the
  layer's apply. The commands are `vector.edit.strokeCap` and `vector.edit.strokeJoin` (`null`
  clears). The Design panel's Point section gets End point and Join dropdowns, each with a "Same as
  layer" option. The stroke is drawn run by run and each point's own join is drawn over the break
  (`figcraft_pathops::stroke_outline_of`, new `points.rs`). This applies to the canvas, PNG and PDF,
  and to SVG as outlines. Outline stroke follows it, render bounds count arrowheads from points, and
  the stroke cache key includes the points' styles.
- **Pen outside edit mode.** Clicking one of the path's own points (other than the first or last)
  connects there and ends the path, as a vector network. With an open vector selected, clicking one
  of its end points continues that path in edit mode.
- MCP: headless `key` Enter on shapes now enters vector edit mode, as in the desktop app. The new
  commands have Spanish labels, and the new UI string has en and es entries.
- Docs: target-app-parity (area 4: 98%/72% to 99%/73%, 2-4 h to 1-3 h; area 20: 80%/55% to
  88%/60%, 2-5 h to 1-3 h), ROADMAP, gaps (gap 9 is down to tablet pressure on macOS and Linux; the
  gap 13 note is updated), ui-parity and mcp.md. Totals are recomputed by the doc's method, because
  rows added since the last recompute had left them stale. Feature breadth is now ~83% and ready for
  real work ~52%. Mainstream stays at ~49% (49.7%, rounded down). Full parity goes from
  ~306-595 h to ~294-572 h.
2026-10-12 02:52:18 +07:00
bflatastic bf82f7ebf9 M4: text lists, hanging punctuation and decoration styles
The tail of the text area, from the public documentation of the target app's text settings, with a table-driven behaviour suite (each case derived in one line; "silent" cases pin today's behaviour).

- **Lists per paragraph** (`TextData::paragraphs`, one `ParaList {list, indent}` per paragraph; empty when the layer has one list style at level 0, so old files load and save unchanged). The list buttons, Shift+Cmd+8 / Shift+Cmd+7, `text.listStyle` and Type settings act on the paragraphs the selection touches while editing (the whole layer otherwise). The per-paragraph list follows edits: a new paragraph copies the one it split from, joined paragraphs keep the first one's.
- **Nesting** up to 8 levels: Tab nests a list item, Shift+Tab moves it out; Enter on an empty item and Backspace at an item's start move it a level out, then out of the list. Typing `- `, `* ` or `1. ` at the start of a plain paragraph starts a list. Numbers count per level (1. a. i., then round again), bullets go • ◦ ▪; a plain paragraph, a shallower item or the other list kind starts the count again. Each level indents by one marker gutter; a nested bullet a font lacks comes from a fallback font.
- **List spacing** (space between two items of a list, in place of paragraph spacing), **hanging lists** (the outer gutter outside the box, item text lined up with plain paragraphs) and **hanging punctuation** (opening quotes and brackets outside the start edge of left-aligned and justified lines, closing punctuation outside the end edge of right-aligned and justified ones; hung punctuation takes no room when lines wrap).
- **Decoration look** per character (`TextStyle::decoration_look`, boxed, None by default): style (solid, dotted, wavy), thickness and underline offset (auto, px or % of the font size), skip ink (underlines break around descenders) and colour (its own style index in the layout, so canvas, PNG and PDF paint it without renderer changes). Decorations are drawn from `Decoration::path()` (bar, dots or wave) on the canvas, in PNG, PDF and outlined SVG.
- `node.set` keys: `listStyle` and `indentation` (range-aware with start/end), `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`, `textDecorationThickness`, `textDecorationOffset`, `textDecorationSkipInk`, `textDecorationColor`; documented in `KEYS` and docs/mcp.md. `text.edit.key` documents Tab.
- Inspect: `listStyle`, or `lineTypes` and `lineIndentations` per paragraph; `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration keys. CSS: `text-decoration-style`, `-thickness`, `-color`, `text-underline-offset`, `text-decoration-skip-ink: none` (browsers skip ink by default). Dev Mode rows: decoration style, list spacing.
- REST import: `lineTypes` with `lineIndentations` per paragraph (they collapsed to one list style for the layer before), `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration style, thickness, offset, skip ink and colour; instance override names for them.
- Type settings ▸ Basics: decoration style, thickness and offset fields (with Auto), Skip ink, decoration colour; List spacing; Hanging punctuation and Hanging list checkboxes; the list control acts on the edited paragraphs. New strings in en/es.
- Components carry the new text fields in the characters and text-style override groups; scaling scales list spacing and px decoration lengths.
- `TextData::path` is boxed (`Option<Box<TextPath>>`) to keep `NodeKind` under clippy's enum size limit after the new fields.
2026-10-12 02:09:10 +07:00
bflatastic a2602071d7 M3: paint and effects behaviour suite and fixes
**A paint and effects behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (fill stacking and opacity, paint vs layer opacity, every blend mode per paint and per layer, pass-through and Normal containers, the four gradient kinds and their handles, stops, image scale modes, rotation and adjustments, the colour models and hex entry, the document colour profile, drop and inner shadows, showing shadows behind transparent areas, layer and background blurs, effects on groups, effect order, what exports include) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins today's behaviour. Scenes are small and synthetic; pixel cases use tolerances (blurs are checked against the normal distribution with sigma = radius / 2).

**Fixes (13 render cases and 8 command/export cases fail on the old code)**
- **Pass-through and Normal containers**: a group or frame set to Normal now blends its children among themselves first (it never isolated), and a **clipping frame no longer isolates** its children: they were drawn on a fresh layer, so a Multiply child inside a frame (frames clip by default) met neither the frame's fill nor what is behind it. The clipped layer now starts as a copy of what lies under it and goes back in its place through the clip (a memcpy for rectangular clips, so no slower); backdrop effects see the right composite, also for a clipping frame inside a faded group. Same in exports: SVG gives Normal groups `isolation:isolate` and puts a clipping frame's clip on what blends instead of a clipped `<g>` (a clipped group is a stacking context, which isolates); PDF wraps Normal groups in an isolated group (`Node::isolates_children`, shared).
- **Plus darker** (linear burn) is drawn by its formula on the canvas and in pixel exports for paints, layers, shadows and noise (it was multiply); tiny-skia lacks the mode, so `fx::plus_darker` composites it and `paint::composite` routes every layer, shadow and noise composite through it.
- **Shadows hidden behind the layer**: a drop shadow that doesn't show behind transparent areas is now hidden behind what the layer covers, translucent and 0% fills included (it was cut out by the layer's alpha, so it showed through a 50% fill anyway); groups and layers whose pixels a layer blur or texture moves still hide it behind their pixels. One `shadow::Caster::new` serves the canvas, PNG and the PDF's shadow rasters. **SVG** never cut shadows out: its filters now composite them `out` of an image of the layer's outline (or `SourceAlpha` for groups), and the filter region reaches as far as the effects (a fixed 200% box cut off a small layer's long shadow).
- **Fill images cover the box to its edges**: an upscaled Fill (or covering Crop) image faded to transparent over a source pixel at the box's edge.
- **"Normal" meant pass-through** when named: `BlendMode::from_name("normal")` found pass-through first (its CSS keyword is also `normal`), so `node.set {blend: "normal"}` and the blend menu's Normal set pass-through. Labels now win, and the menu sends labels.

**Added**
- A blend mode menu for each paint in the colour picker (no pass-through for paints), sharing `right::blend_menu` with the Design panel's layer menu; strings already translated.
- Inspect (`node.get`, MCP) reports a solid paint's `blend` when it isn't normal.
2026-10-12 01:32:04 +07:00
BFlat d01d18cd88 M2: auto layout behaviour suite and fixes (#130)
A 64-case behaviour suite derived from public documentation (sizing, padding, strokes, gaps, alignment, wrap, absolute position, grid, stacking) plus command-level tests. Fixes: frames never shrink below their padding, only the frame's own inside stroke counts when strokes are included (now the default for new auto layout), auto gaps stop at 0, grid fr tracks never go below their content, and canvas stacking (reverseZ) now drives drawing, exports and hit testing. New settings: evenly/around auto gaps, vertical wrap with a column gap, per-child grid cell alignment, strokes included/excluded and canvas stacking in the Design panel.
2026-10-11 17:53:11 +07:00
BFlat cb5435d5ff M4: stretch and scatter brushes, pen pressure (#120)
Strokes can use a brush: eight presets drawn in code (four stretched along the path, four scattered with spacing, size, rotation and offset jitter from a fixed seed), drawn by the shared stroke-outline code in canvas, PNG, SVG, PDF and outline stroke, with caps on copies and steps. Pointer events carry pen pressure; the Pencil records it as the stroke's width profile, which brushes follow (Windows pens and browser styluses; constant width elsewhere).
2026-10-11 13:43:48 +07:00
BFlat f62cb4b1f6 M4: text on a path and pattern fills (#118)
Text layers can follow a path (start offset, flip, alignment; edited in place; canvas, PNG, SVG, PDF; SVG textPath imports as real text on a path). A new pattern paint tiles a source layer in grid or hex rows with scale, spacing and alignment, live as the source changes (SVG pattern, PDF vector tiling pattern).
2026-10-11 13:25:33 +07:00
BFlat 5b321a779c M6: animated GIF fills and TIFF images (#116)
Animated GIF image fills keep their frames (first frame on the canvas and in exports; the presenter and preview play them, capped frames and memory, idle when not playing). TIFF images open, import, place and drop (pure-Rust decoder already in the tree); SVG exports embed TIFF and BMP as PNG. HEIC and video stay out (non-Rust decoders).
2026-10-11 11:51:37 +07:00
BFlat ecb3656c69 M1: open recent, outlines for the selection, mask outlines (#115)
The last three catalogued menu items got commands: File > Open recent (recent files kept in preferences, capped, missing files greyed and dropped when picked, Clear recent), outlines for the selected layers only, and mask outlines. Menu breadth 100%.
2026-10-11 11:26:57 +07:00
BFlat 08d91869cb M4: live boolean editing, arc fields, shape builder on strokes, one-sided widths (#111)
Boolean operands are editable in place with live results (per-operand operations, cached prefixes so a drag recomputes from the edited operand up); a Design panel Arc section; the shape builder takes strokes and open paths as outlined areas; variable width points can be wider on one side.
2026-10-11 10:04:25 +07:00
BFlatandClaude Opus 5.5 4904303fc2 M8: retained canvas correctness (#89)
* M8: retained canvas correctness

Drags now show live on the canvas. The canvas used to drop every finished
render whose request had been superseded, and it asks for the next render
before it polls. So while an edit lands every frame (any drag), no image was
ever shown until the pointer was released. Holding the button still made it
worse: the same move ran the gesture again each frame, built a new document
and asked for a new render, which kept the canvas stuck on the pre-drag image.

- Canvas: show every finished render (renders finish in order; the image is
  re-projected to the current view). The generation filter is gone.
- Engine: Session::pointer skips a drag move identical to the last one, and
  Session::preview keeps the document when the preview changes nothing (a
  handle held still).
- Render: clip and layer rectangles snap edges that are within float noise of
  a pixel boundary before rounding outwards. An edge exactly on a boundary came
  out a hair to either side depending on the render's origin, so a clip let a
  whole column through in some renders and not others.
- Tests: a seeded random walk compares the retained render with a full render
  after every step: edits, undo/redo, reparenting, masks, groups, effects,
  strokes, text, auto layout, instances, variables, styles, pans and zooms. It
  found the clip-edge divergence; a minimised regression test covers it. Also
  a headless UI test for a live drag, and engine tests for skipped repeated
  moves and unchanged previews.

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

* M8: retained canvas tests follow the new blur effect type

Layer blurs are `LayerBlur(Blur)` since the progressive blur landed; the random
walk now also tries progressive layer blurs.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:40:26 +07:00
BFlatandClaude Opus 5.5 70b2666c90 M3: progressive blur, inner shadows under strokes, noise and texture in SVG (#86)
- Inner shadows paint over the fills and under the children and strokes, as documented (canvas,
  PNG, PDF and SVG alike; text fills every run before the inner shadow and the strokes).
- Layer and background blur gain a progressive type: the radius ramps linearly from a start radius
  at a start point to the end radius at an end point (unit-box points, like gradient handles).
  Rendered by blending a few geometric blur levels, each blurred only around the pixels that use
  it; bands and partial renders match one piece. Design panel type menu and Start/End fields,
  on-canvas handles while the settings are open, effect.add/effect.set params, startRadius
  variable bindings, scale tool, REST import (blurType, startRadius, startOffset, endOffset),
  Inspect row; CSS leaves progressive blurs out.
- SVG export embeds renders for what filters can't match: noise grains over the vector layer
  (crisp, blended), texture and progressive layer blur whole, background blur and glass from what
  lies under the layer among the exported layers. PDF and SVG share the renderer's helpers.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:20:07 +07:00
BFlatandClaude Opus 5.5 70ab71bee6 M2: auto layout keyboard reordering and section hide contents (#78)
* M2: auto layout keyboard reordering, nested auto layout fixes, section hide contents and dev status

- Arrow keys move auto layout children one place: along the flow (past hidden and
  absolute siblings), between wrapped rows, to the neighbouring grid cell, and into
  the neighbouring nested auto layout frame; no undo step when nothing moves.
- Sections: section.toggleContents (hide contents), object.wrapInSection, frames in
  sections are top-level frames (labels, flows, navigation, PDF export), navigating
  to a section shows its frame shown last.
- Ready for dev turns "Changed" when contents are edited; dev.resolveChange with a reason.

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

* M2: README and ROADMAP for auto layout arrow keys and sections

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

* M7: roadmap refresh after #68–#77

Parity table rows for canvas, selection and transform, layers, effects,
text, prototyping and comments now reflect the merged work; weighted
feature depth ~76%; README command count and hand-off features.

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

* M2: the dev change reason resolves only on Enter or Resolve

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:36:02 +07:00
BFlatandClaude Opus 5.5 f9b4609631 M3: effects fidelity: backdrop blur in clipped frames, exact shadow spread, glass, noise and texture (#69)
- Background blur sees the whole backdrop under the layer: the renderer keeps the stack of
  isolated surfaces it draws into (clipping frames, rounded clips, faded groups, masks) and
  flattens it for the layer's area, reading past the layer's own box. A blur inside a clipping
  frame now shows the frame's fill instead of the sharp layers below, on the canvas and in PNG
  exports. The PDF export draws background blur and glass through the same code from a render of
  what lies under the node (`backdrop::render_beneath`, which the PDF's backdrop now shares), and
  its images match the PNG's pixels.
- Shadow spread offsets the outline: the shape filled and stroked twice the spread wide with
  mitred joins (grow) or cut away (shrink), the stroked bands cached per outline. Ellipses stay
  ellipses, stars keep sharp points, text keeps its letterforms, rounded corners grow their
  radius. Inner shadows shrink the outline the same way. Canvas, PNG and PDF share the shadow
  code; SVG writes spread shadows as the same geometry and now exports inner shadows.
- Glass effect (light angle and intensity, refraction, depth, dispersion, frost, splay): the
  frosted backdrop bent towards the inside within `depth` of the edge, colour fringes, light on
  the rim, from an exact distance transform with smoothed normals.
- Noise: mono, duo and multi colour modes, grain size, density, opacity and blend mode; grains
  live in the layer's coordinates (stable when panning and zooming). Texture is now drawn
  (pixels scattered by the radius, grain size, clip to shape).
- Design panel rows for noise mode/colours/opacity, texture clip to shape and every glass value;
  `effect.add` takes `glass`; REST import of NOISE, TEXTURE and GLASS; Inspect rows and CSS;
  the scale tool scales grain sizes, texture radius, glass depth and frost.
- Tests in new modules: render backdrop/spread/grain tests, PDF backdrop tests, SVG, doc,
  engine and REST effect tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:59:59 +07:00
BFlatandClaude Opus 5.5 5f54fecca2 M8: 50k-layer zoomed-out renders and faster save/load (#63)
Canvas previews zoomed out to 50% or less draw levels of detail
(crates/render/src/lod.rs): subtrees under 2 device px are splatted as
their averaged paint colours (box filter, no paths or effects), text
under 4 px is greeked into bars, small solid rectangles are splatted and
rectangular clip layers are composited without the blend pipeline.
Image paints and masks keep the exact path. Exports, thumbnails and
every other render stay exact (Options::lod is off by default); render
bands are assembled with a row copy instead of a pixmap draw.

.figcraft documents are serialized straight into the compressor and
parsed straight from the text (a header pass reads version and image
index without building a value tree); layer and text fields at their
default values are left out (63 -> 22 MB of JSON at 50k layers, older
files still load); JSON members use deflate level 3.

At 50k layers: zoomed-to-fit render ~58 -> ~8 ms, save ~1.0 s -> ~0.09 s,
load ~0.86 s -> ~0.16 s.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:51 +07:00
BFlatandClaude Opus 5.5 1184dd4177 M4: variable width strokes (#58)
A stroke can carry a width profile: widths at positions along the path
(each subpath by length), interpolated smoothly (monotone cubic).

- doc: WidthProfile / WidthPoint / WidthPreset (uniform, wedge, taper both
  ends, eye, rounded, pinch) on StrokeProps, serde-defaulted so older files
  load; paint reach and hit testing follow the widest point.
- pathops: path measuring by length and the variable outline (offset along
  the normals, round/miter/bevel joins, caps, dashes keep their width,
  Inside/Outside clip as before; bounded samples and dashes, uniform
  fallback). Canvas, PNG, SVG (filled outline), PDF and outline stroke use it.
- engine: stroke.widthProfile {preset | points}, node.set widthProfile,
  inspect output; the Variable width tool (Shift+W) in vector edit mode:
  drag the path to add a width point, slide points, drag their handles,
  Delete removes the selected one; vector.edit.widthPoint add/set/remove.
  Every gesture is one undo step.
- UI: width profile dropdown with previews drawn in code and a flip button
  in the stroke settings; the tool in the edit toolbar (new icon drawn in
  code); width handles and a hover preview on the canvas; Position/Width
  fields for the selected width point.
- Tests: profile maths, outlines, render regressions, tool gestures,
  commands, exports and junk sweeps. ROADMAP, README, docs updated.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:12:21 +07:00
BFlatandClaude Opus 5.5 a5d5c265fd M7: performance: retained canvas renders, virtual layers panel, shared text layouts, cargo xtask bench (#35)
Large documents stay interactive: the canvas re-renders only what a pan uncovers or an edit
touches, the layers panel lays out only the rows in view, and per-frame work no longer walks the
whole page.

- Benchmark: `cargo xtask bench [SIZES]` builds synthetic pages (`figcraft_engine::synthetic`:
  screens with an auto layout header, a card group, button instances and shapes/text) at
  1k/10k/50k layers and prints engine/render timings (`figcraft-cli perf --layers 1000,10000`) and
  headless whole-UI frame timings (an ignored ui-egui test).
- Retained canvas rendering (`figcraft_render::Retained`, used by the render worker): views snap
  to whole device pixels, so a pan shifts the previous image in place and renders only the
  uncovered strips; an edit renders only where changed layers were and are
  (`Document::changed_ids` diffs the two versions skipping shared structure; ancestors that paint
  from their children's pixels - booleans, effects, masks - count whole; background blur reach
  and noise are allowed for). Zoom, page, options, font or page-level changes, or large changes,
  render the whole view as before. `ui.inspect` reports `canvas.renderWork`.
- The texture image is converted on the render thread, not the UI thread.
- Layers panel: only visible rows are laid out (stable ids while scrolling); rows are cached per
  document version, page, expanded set and filter.
- Frame labels and layout grids of off-screen top-level frames are skipped; the Prototype tab's
  connection sources are cached per document version.
- Text: the layout cache keys by a cheap fingerprint confirmed by `TextData` equality (no Debug
  formatting per lookup, 32k entries) and hands out shared layouts; a layout generation marks
  renders stale when fonts arrive. Overflowing text is culled by its glyphs, so it no longer
  vanishes while its box is off screen.

Before -> after (release, Windows, 1440x900 view, ms; 10k / 50k layers):
- move one layer, re-render @100%:     7.65 / 6.49  -> 0.18 / 0.81
- move one layer, re-render zoomed out: 11.95 / 24.10 -> 0.16 / 0.77
- pan 40 px @100%:                     7.53 / 6.82  -> 0.69 / 0.81
- pan 40 px zoomed out:                12.07 / 24.30 -> 1.62 / 4.17
- full render @100% / zoomed to fit:   7.78 / 44.22 -> 6.56 / 38.83 (10k @100%, 50k fit)
- UI frame, every screen expanded in layers: 6.16 / 39.07 -> 0.24 / 0.57
- UI frame, scrolling that panel:      6.11 / 40.18 -> 0.43 / 0.40
- UI frame, layers filtered by name:   1.03 / 6.92  -> 0.17 / 0.36
- UI frame, Prototype tab:             0.89 / 6.45  -> 0.18 / 0.80
- UI frame, idle:                      0.31 / 1.43  -> 0.11 / 0.39
Unchanged and within budget: single-layer edits through the engine ~0.01 ms, normalize after a
one-node edit < 0.01 ms, hit test 26 / 93 us. Left: save/load at 50k (~0.7 s each), zoomed-out
renders at 50k (~40 ms).

Tests: retained renders match full renders after pans, moves, recolours, hide/remove/add, undo,
masks, overflowing text, edits under a background blur and an inner-shadow/noise layer, a drop-
shadowed group, and a blur panned in from the view's edge; zoom/page/options/page background
render full; junk views don't panic. Layer-row and prototype-source caches follow edits, undo,
page switches, expansion and the filter. `changed_ids`, the text cache, and the perf CLI.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:19:05 +07:00
BFlatandClaude Opus 5.5 15ccc379ab M1: fix inner shadow bands: shadow from the shape, not the layer's crop; banded rendering matches one piece (#24)
The canvas renders large views in parallel horizontal bands, and each band crops a layer to its
own rows. Three effects went wrong at those crops:

- Inner shadow (the reported bug): the padded shadow buffer treated everything beyond the cropped
  layer as outside the shape, so every band edge cast shadow inwards, drawing dark bands across
  the layer (any shape and fill; also at the view's edge when a layer was partly off screen). The
  shape itself is now rasterised over the padded buffer at the shadow's offset (sub-pixel
  accurate), so only its real edges cast shadow.
- Layers starting above or left of the pixmap (inside the blur margin) were sized from the
  clamped origin and came out short by the margin: a drop-shadowed or background-blurred layer
  crossing a band edge lost a strip at its far side.
- Background blur read a backdrop that stopped at the band's edge (streaks at band edges). Bands
  now overlap by what background blurs read (3 sigma + box rounding) and keep only their own
  rows; a blur wider than a band renders the view in one piece.

Regression tests (crates/render/src/band_tests.rs): an image-filled ellipse and a rotated solid
rectangle with inner shadows (spread, offsets) under a background-blurred panel, at 1x and 2x,
rendered in 2, 3 and 7 bands, must match the one-piece render row by row; the ellipse's interior
far from its edge must equal the render without the shadow on every row; a huge backdrop blur
falls back to one piece.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:09:04 +07:00
BFlatandClaude Opus 5.5 840d3ac5d2 M1: mask types: alpha, vector outline and luminance masks (#17)
- Layers gain `maskType` (alpha by default, so files are unchanged): a vector mask clips by the
  mask layer's outline whatever its fills and opacity; a luminance mask shows content where the
  mask is bright and hides it where it is dark.
- `node.set {maskType}` (validated), `node.get` reports it, and the Appearance section shows a
  Mask dropdown when the selection is masks.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:45:45 +07:00
Brandon ThomasandClaude Opus 5.5 ab5914485e Perf: persistent node map (imbl) and dirty tracking; commands now scale with what changed (node.create 0.2 ms at 20k nodes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:49:04 -04:00
Brandon ThomasandClaude Opus 5.5 b26b449fe3 Fixes from agent reports: hex colours everywhere, camelCase in/out, unique variable ids, strict batch, ui.key modifiers, export rounding, Fit images, inner shadows on all edges
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:34:25 -04:00
Brandon ThomasandClaude Opus 5.5 b36fd771ab M0.9: MCP server + figcraft-cli, PDF export, web app, release/CI packaging, vector edit mode, workspace lint cleanup
- crates/mcp: JSON-RPC MCP server (18 tools, resources, prompts), headless and attached backends, end-to-end agent tests
- apps/figcraft-cli: info/export/convert/render/run/commands/mcp/perf
- crates/pdf: vector PDF export via krilla with embedded text; file.exportFramesPdf
- apps/figcraft-web + packaging/{macos,windows,linux,freebsd,web} + release/ci workflows
- engine vectoredit: edit points/handles/bend/add/delete-heal; canvas overlay and edit strip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:31:53 -04:00
Brandon ThomasandClaude Opus 5.5 b367392c4b M0.4: text (shaping, layout), pathops (booleans, aligned strokes), render (tiny-skia scene renderer)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:12:34 -04:00