mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
Conflicts resolved per file (both sides kept unless noted):
crates/engine/src/atomic.rs | 1 | main's: `write_file` removed (main routes command writes through `crate::io::write_file`); its one caller in file.rs now uses `crate::io::write_file`
crates/engine/src/cmd/datamerge/fill.rs | 1 | next's `Graphic` literal (has `clip` and `wrap`, which next's `Graphic` carries)
crates/engine/src/cmd/datamerge/mod.rs | 1 | main's `read_path_capped` (crate::io metadata/read_capped, roots checked); next's capped read is the same behaviour
crates/engine/src/cmd/datamerge/parse.rs | 1 | main's (`crate::io::metadata`, `crate::io::Metadata`)
crates/engine/src/cmd/edit.rs | 1 | next's `spread_param(p, "edit.paste")` (the second argument is the command id on both sides)
crates/engine/src/cmd/export.rs | 3 | main's: EPUB params doc, `toc_title` once (main's position; next's duplicate dropped)
crates/engine/src/cmd/file.rs | 7 | place doc: next's (lists page/rect/autoflow params #803 checks); next's recent-file functions + main's `foreign_document`; next's open (load_bytes, warnings, non-DesignCraft refusal) with main's checked `crate::io::read_file` for the bytes; main's `check_write` be
crates/engine/src/cmd/find.rs | 7 | main's Find scope (FindContext, scope/selection/story, continuation, UTF-8 repair) on next's `designcraft_compose::grep::Grep` (dialect GREP): find uses `find_all` capped at MAX_MATCHES, change uses `replacements`/`find_all` (literal change text not expanded), fin
crates/engine/src/cmd/fonts.rs | 1 | next's (`has_style`, resolved/replacement face); `has_style` already compares styles ignoring spacing and case, which is main's `same_style` fix; main's test kept
crates/engine/src/cmd/interchange.rs | 1 | next's `import_idml_report_with_previews` (previews for unreadable links)
crates/engine/src/cmd/layout.rs | 8 | pages.insert: main's overflow-safe `after` + next's checked `parent_ref`; main's duplicateSpread doc; layout.section: next's `SectionEdit` (already keeps unnamed settings, #717) with main's `d.repaginate()` moved into `SectionEdit::apply` (remove and set paths),
crates/engine/src/cmd/links.rs | 5 | next's links.list (no doc clone); main's `text_status` via crate::io; `use std::io::Read` dropped (unused after main's read_capped); test literals main's. `link_missing` (next) uses `crate::io::exists` for the file-guard scan
crates/engine/src/cmd/object.rs | 4 | object.fill/stroke tint-alone: main's (23e1680e7; out-of-range tint is an error, which main's test asserts); object.fit doc: next's (lists `axis`)
crates/engine/src/cmd/paths.rs | 1 | next's (shatai transform on outlines)
crates/engine/src/cmd/place_text.rs | 1 | next's `add_flow_frame` (used by autoflow)
crates/engine/src/cmd/preflight.rs | 2 | next's overset/unplacedStory issue (its `overset_location` already applies the parent transform main's #738 fix adds); next's two test modules kept with main's
crates/engine/src/cmd/scripts.rs | 1 | combined: next's `in_undo_step = false` (#406) + main's full rollback of a failed script (#709)
crates/engine/src/cmd/style.rs | 14 | param docs next's (gradient swatch.create, swatch stops, model colours); create_para next's; rename_style main's structure (rename entry, then `redirect_style_refs`), redirect has next's full reference set (cells, defaults, notes, TOC) + main's never-based-on-it
crates/engine/src/cmd/text.rs | 10 | main's selectSentence/Paragraph/Story + select_around; next's selectionAttrs (mixed) + main's openTypeFeatures/type.feature; main's comments; tracked typing: next's `reserve_command_text` + main's `insert_format_at`; empty-paragraph mark formatting main's (with `
crates/engine/src/cmd/thread.rs | 1 | next's `spread_param(p, "text.thread")`
crates/engine/src/dtd.rs | 1 | next's linear, depth-bounded `expand_parameter_entities` with main's budget (input + 1 MiB, `MAX_EXPANDED_BYTES`) and message ("expand the DTD beyond"); 16 MiB constant removed; both sides' tests pass on message
crates/engine/src/guard.rs | 2 | main's whole-transaction snapshot (docs, active, journal; #742) + next's `in_undo_step` restore; tests both
crates/engine/src/lib.rs | 5 | LoadedGraphic doc next's; next's `new_document_preview`; execute: main's `discard_stale_find_scope()` before next's nested-undo run; modules both (tests_autosize, tests_file_guard, tests_params, tests_roots)
crates/engine/src/recovery.rs | 1 | next's test (superset of main's assertion)
crates/engine/src/tests.rs | 4 | moved tests deduplicated (identical on both sides); next's preflight and direct-selection/kerning tests + main's style-delete, locked-frame and baseline-fit tests
crates/engine/src/tests_idml.rs | 3 | main's order (drop cap and margins tests identical on both sides, once each)
crates/ui-egui/src/canvas.rs | 3 | next's screen_options/DisplayKey block and tests kept; two tests that auto-merge had added twice (parent_guides_on_a_page_are_not_picked_there, baseline_grid_lines_are_the_ones_on_the_page) kept once; main's `typing`/`screen_at`/`click` helpers and its tests clicks
crates/ui-egui/src/control.rs | 3 | main's `count` check and pending budget (control queue + synthetic queue); exact-budget test runs both sides' cases (click 255 on 9,492 pending; drag 9492×255)
crates/ui-egui/src/cursors.rs | 1 | main's (same Pen cursor set)
crates/ui-egui/src/dialogs.rs | 14 | dialog body (newDocument … alert match) is next's text, with main's Find/Change scope reset (`set_field`, `reset_find_scope`, `find_change_params`) and main's Create Guides / Margins and Columns arms ported in; `Dialog::new` ruby section (next) + main's `set_fi
crates/ui-egui/src/i18n.rs | 7 | ka and pl modules, languages, statics and lookups both; `category_first` covers uk, ru, pl, ka and `count_label` keeps next's Czech plurals; shared TABLE union; next's duplicate rows for Auto, Number:, Gutter:, Top:, Bottom:, Inside:, Outside:, Left:, Right: dropped
crates/ui-egui/src/i18n/ar.rs | 2 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/it.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/ja.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/pt_br.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/uk.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/icons.rs | 2 | both tests (main's typography icon, next's distribute icons)
crates/ui-egui/src/lib.rs | 11 | UiState: next's tabs_panel + main's last_color/last_gradient; `cursors` field once; next's SVG paste and paste-on-spread-in-view; next's extensionless document open; opens_as_document next's comment; tests both (next's control bar / key-name tests, main's rich black
crates/ui-egui/src/menus.rs | 15 | app.language doc next's text with pl and ka; UI_COMMANDS both (next's Tabs, Toggle Presentation, Apply None text; main's panel commands, Apply Color, Apply Gradient); Edit › Interface Language stays out (next moved it to Preferences); Type menu main's order with
crates/ui-egui/src/panels/glyphs.rs | 1 | next's (already has main's scroll-bar fix); the scroll-bar test module auto-merge had added twice kept once
crates/ui-egui/src/panels/layers.rs | 3 | next's drag-and-drop `object_rows`, with main's features ported into it: selection reveal (layer opens, row scrolls into view once), selected rows highlighted (also a group whose member is selected), double-click rename in place (`object.rename` queued like
crates/ui-egui/src/panels/mod.rs | 6 | tabs + typography modules and color_panel_tests; SelInfo has wrap_side/wrap_contour and wrap_offsets (offsets from the graphic's own wrap when it is selected); next's swatch_picker returning the chip response; next's content_selection_tests
crates/ui-egui/src/panels/pages.rs | 1 | next's (parents dragged by id)
crates/ui-egui/src/panels/properties.rs | 4 | next's swatch popup (Apply To, Tint); main's four wrap offsets then next's Wrap To / Contour Type; test fixtures main's (`..Default::default()`, no unused builder)
crates/ui-egui/src/panels/swatches.rs | 3 | main's Delete Swatch button + next's New Swatch comment; next's draggable rows; click: main's PICKED + fill/stroke in front, with next's no-apply on double-click
crates/ui-egui/src/toolbar.rs | 2 | main's Apply Color / Gradient / None wells and functions; next's apply_none doc line
crates/compose/src/lib.rs | 5 | next's line loop kept (mark_leading + last_line_mark, wrap segments/carry, grid gyoudori); main's mark-chars base_leading block (auto-merged) stays beside it. `fits`: next's #106 rule (baseline fit for horizontal frames, full descent for paths, vertical text, footnote
crates/compose/src/tests.rs | 5 | next's tests (tombstone set, sliver/one-sided/contour/clipping wrap tests, borders, lists, kenten, grid, anchored wrap, balance, shaped frames) plus main's an_empty_last_paragraph_isnt_overset; main's wrap_object_beside_one_narrow_column_leaves_the_other_alone re-ad
crates/doc/src/assets.rs | 1 | main's doc comment (same meaning)
crates/doc/src/build.rs | 3 | parent_page_for: next's parent_page_for_side (RTL-aware); single_sided_page_shows_first_parent_page test kept once, at main's position
crates/doc/src/item.rs | 1 | next's rotated_group_bounds_follow_the_children_outlines test kept
crates/doc/src/lib.rs | 1 | next's kerning_increment field kept; main's "Bulleted & Numbered" wording
crates/doc/src/page.rs | 1 | next's outer_page_indices + parent_page_for_side (integration of parent-margins with rtl-master-sides); main's parent_page_index dropped, its only caller (build.rs) uses parent_page_for_side
crates/doc/src/slice.rs | 5 | extract: next's `marked()` (get-based, fix_notes/fix_endnotes/fix_marks; main's version indexed slices and could panic); insert_story: main's take() limit on endnote/editorial payload copies with next's id comment, redundant `let ne`/`let nn` dropped; main's excess_note
crates/geom/src/corners.rs | 1 | next's box_outline_rounds_only_closed_corners test kept
crates/render/src/damage.rs | 1 | next's wrapping-anchored-object damage test kept
crates/render/src/fx.rs | 2 | feather: next's choke/corners sigma+join inside main's structure (body and clip at device resolution, with_filters(.., None)); inner glow centre: next's blend `mix` with main's clip argument (bp, xf)
crates/render/src/lib.rs | 3 | next's layer comment; next's front baseline grid draw after items; gradient match on g.kind (main's tuple scrutinee did not match the single-pattern arms)
crates/render/src/text.rs | 5 | paint_glyphs: main's paint_stroke closure structure with next's stroke overprint (overprinting helper, stroke `(TextPaint, bool)`); callers pass next's overprint_fill/overprint_stroke
crates/render/tests/placed_pdf.rs | 1 | main's `near` helper version
crates/tools/src/lib.rs | 1 | main's (PenConvert already declared below, avoids a duplicate variant)
crates/tools/src/pen.rs | 1 | main's module doc
crates/tools/src/select.rs | 1 | next's Drag::Corner variant (live corners) kept
crates/tools/src/tests.rs | 4 | next's tests (content drag, live corners, table border drag, eyedropper matchFrom, fitting handles, pen cursors once); eyedropper_ignores_inactive keeps next's samples_image check (main's exact-JSON compare lacks matchFrom) with main's `..Default::default()` Asset lit
crates/tools/src/text.rs | 2 | next's table border drag (BorderDrag, border_at) kept, then main's comment on locked text frames
crates/tools/src/xform.rs | 2 | main's explicit `Vec<&Item>` for shown children; next's eyedropper matchFrom (integration 796e9d713)
Cargo.toml | 2 | main's craft-pdf workspace dep added (craft-print stays once, in next's craft-libs block); [patch.crates-io] keeps next's winit (rust-windowing #4694 backport) and adds main's krilla patch; main's skylartaylor winit left out (see UNSURE 1)
Cargo.lock | 1 | union: next's craft-atomic and craft-cms entries plus main's craft-pdf entry; needs `cargo` normalisation
README.md | 1 | Open bullet combines INDD import via indd-utils (next) and editable PowerPoint export (main); main's paragraph break kept
contributors/people.toml | 1 | both people kept (switch87 from next, Nmarello from main)
apps/designcraft-cli/src/main.rs | 4 | next's `Export` enum dispatch kept; main's .pptx export added as `Export::Pptx` (run prints slides and warnings, script runs file.exportPptx); main's `--pdf-options` null-as-{} parse; usage lines list .pptx
apps/designcraft/src/main.rs | 3 | `startup.leave()` right after `app.ui` (main #380) and next's macOS `sync_movable` kept after the IME discard; next's Apple Events install kept; main's macOS Dock Quit `with_close_requested_on_terminate` block left out (needs the skylartaylor winit; UNSURE 1)
crates/fonts/src/fontdb.rs | 9 | FONT6 aliases (next) combined with #911 WWS names (main): ScannedFace and both CatalogEntry constructors carry `wws` and `aliases`; `canonical_family` falls back to WWS family names; `exact_style_traits` canonicalises then tries typographic, then WWS names (`named_st
crates/format/src/lib.rs | 1 | tests: both sides kept (next's r7a nullable-default tests; main's oversized ZIP entry/asset/entry-count tests)
crates/idml/src/import.rs | 11 | main's bounded `Package` reader under next's `import_idml_report_with_previews` (metadata read through `pkg.part`); next's `tab_list_repaired` kept; ruler origin: next's `settings.ruler_origin`, and main's guide import reads it (main's private `RulerOrigin` enum and
crates/idml/src/tests.rs | 6 | item-level union: all next tests plus main's new ones (ruler guides, package limits, CJK leading model, liquid page rules module, mojikumi); no test dropped
crates/mcp/src/backend.rs | 1 | test comment merged from both
crates/pdf/src/tests.rs | 1 | tests: both sides kept
crates/pdf/src/text.rs | 4 | next's (skew `tan`, cluster `start`, ruby overprint warning); main had no change here beyond base
crates/textimport/src/docx.rs | 5 | next's (ListCounter integration for list levels, page-break test position); main's side was the older form of the same code
crates/epub/src/lib.rs | 30 | non-test code is next's (StoryWriter holds img_src/images/no_alt/heading_ids; covers main's #787/#791/#782 fixes, plus #314 group transforms, anchored images in images_without_alt, endnote story skipped in the TOC); tests: next's set plus main's extra css_weight_style a
docs/control-protocol.md | 2 | next's rows (ui.paste, count 1–255, documentGrid) and wording
docs/file-format-parity.md | 1 | rows combined (next's ICC notes, main's TIFF indexed colour)
docs/gaps.md | 2 | status change notes unioned; Flex Layout removed from missing (main implemented it), next's Parent Options sentence kept
docs/localization-parity.md | 4 | Polish and Georgian rows; 5 languages beyond the twelve; file list includes cs and ka; both history rows
docs/roadmap.md | 1 | M1 next's (#186 fixed on next), M2 main's IME wording
docs/target-app-parity.md | 6 | next's status line; File formats row next's, UI/UX row main's (IME no longer failing); UI line next's plus main's Swatches detail; one Page tool line (main's fuller one); Load styles and main's fuller Pathfinder line both kept, main's duplicate short Pathfinder line r
docs/ui-parity.md | 1 | IME row main's, Font menu row next's
i18n/fr.rs: five rows dropped that main's shared table now has (Row, Open Path, Flex Layout, Current Page Number, Rulers).
462 lines
22 KiB
Rust
462 lines
22 KiB
Rust
//! Soft object effects (Effects panel): drop shadow, outer glow, inner shadow and basic feather,
|
||
//! drawn with vello filter layers.
|
||
//!
|
||
//! vello_cpu only supports filter layers in single-threaded contexts, so on the multithreaded
|
||
//! pipeline each effect is rendered into an offscreen single-threaded context cropped to its
|
||
//! reach and composited back as an image ([`Renderer::with_filters`]); single-threaded contexts
|
||
//! draw directly. A very wide blur goes offscreen in both, at a lower resolution; the object
|
||
//! itself and the clip to its shape stay at full resolution.
|
||
//!
|
||
//! - Drop shadow / outer glow: the object's silhouette (everything it paints: fill, image, text,
|
||
//! stroke) in a `DropShadowOnly` layer under the object. `size` sets the blur (σ = size / 2),
|
||
//! `spread`/`choke` (percent of size) grow the silhouette instead of blurring it. The shadow's
|
||
//! opacity rides on the filter colour's alpha.
|
||
//! - Inner shadow: the inverse of the offset shape, blurred, clipped to the shape, on top.
|
||
//! - Inner glow, bevel and emboss, satin: built like the inner shadow (an offset inverse of the
|
||
//! shape, blurred and clipped) with screen / multiply blending; satin XORs two offset copies.
|
||
//! - Feather: the object is masked by its shape inset by half the width and blurred, so the
|
||
//! edge fades from ~0 at the path to full strength `width` inside.
|
||
|
||
use designcraft_doc::Item;
|
||
use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
|
||
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
|
||
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
|
||
use vello_cpu::{RenderContext, kurbo};
|
||
|
||
use crate::{Frame, Renderer, color_of};
|
||
use designcraft_compose::ShownOn;
|
||
|
||
/// Shadow offset (spread units) for an InDesign angle (degrees, light direction) and distance.
|
||
pub(crate) fn offset(angle: f64, distance: f64) -> Vec2 {
|
||
let a = angle.to_radians();
|
||
Vec2::new(-a.cos() * distance, a.sin() * distance)
|
||
}
|
||
|
||
/// How far (points) the effects of `it` reach outside its geometry.
|
||
pub(crate) fn outset(it: &Item) -> f64 {
|
||
let e = &it.effects;
|
||
let mut o: f64 = 0.0;
|
||
if e.drop_shadow.on {
|
||
o = o.max(e.drop_shadow.distance.abs() + e.drop_shadow.size.abs() * 1.6 + 1.0);
|
||
}
|
||
if e.outer_glow.on {
|
||
o = o.max(e.outer_glow.size.abs() * 1.6 + 1.0);
|
||
}
|
||
o
|
||
}
|
||
|
||
/// (blur σ, growth) in points for an effect `size` with `pct` percent hardened.
|
||
fn split(size: f64, pct: f64) -> (f64, f64) {
|
||
let size = size.max(0.0);
|
||
let p = (pct / 100.0).clamp(0.0, 1.0);
|
||
(size * (1.0 - p) / 2.0, size * p)
|
||
}
|
||
|
||
fn blur(sigma: f64) -> Filter {
|
||
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None })
|
||
}
|
||
|
||
fn shadow(sigma: f64, color: peniko::Color) -> Filter {
|
||
Filter::from_primitive(FilterPrimitive::DropShadowOnly {
|
||
dx: 0.0,
|
||
dy: 0.0,
|
||
std_deviation: sigma.max(0.0) as f32,
|
||
color,
|
||
edge_mode: EdgeMode::None,
|
||
})
|
||
}
|
||
|
||
/// Keep what was drawn in the current layer as much as `it`'s gradient feather says. Cover the
|
||
/// whole viewport: strokes, text and child effects can extend arbitrarily past the geometry,
|
||
/// and past the gradient's ends its end opacities carry on.
|
||
pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine) {
|
||
let gf = &it.effects.gradient_feather;
|
||
let (p0, p1) = gf.points(it.feather_bounds());
|
||
let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8)));
|
||
let stops = [stop(0.0, gf.start), stop(1.0, gf.end)];
|
||
let grad = if gf.radial {
|
||
peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice())
|
||
} else {
|
||
peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice())
|
||
};
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
|
||
ctx.set_paint(grad);
|
||
ctx.set_paint_transform(f.view * xf);
|
||
ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())));
|
||
ctx.reset_paint_transform();
|
||
ctx.pop_layer();
|
||
}
|
||
|
||
impl Renderer {
|
||
/// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow.
|
||
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn) {
|
||
let doc = f.doc;
|
||
let e = &it.effects;
|
||
let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() };
|
||
// Spread-space reach of what the object paints.
|
||
let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0);
|
||
// The silhouette is the shape when the object paints its area (fill / image); text frames
|
||
// without a fill cast the shadow of their text only, which can't be grown.
|
||
let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_)) || it.has_nested_items();
|
||
let mut below = vec![];
|
||
if e.outer_glow.on {
|
||
let g = &e.outer_glow;
|
||
below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity));
|
||
}
|
||
if e.drop_shadow.on {
|
||
let d = &e.drop_shadow;
|
||
below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity));
|
||
}
|
||
for (off, size, pct, color, opacity) in below {
|
||
let Some(c) = doc.resolve_color(&color, 1.0) else { continue };
|
||
let (sigma, grow) = split(size, pct);
|
||
let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0)));
|
||
// The offset moves the geometry, not the filter: vello drops layer content that lies
|
||
// entirely outside the viewport before filtering.
|
||
let r = (reach + off).inflate(grow, grow);
|
||
self.with_filters(ctx, f, r, sigma, None, |me, c, fr| {
|
||
let moved = Frame { view: fr.view * Affine::translate(off), ..*fr };
|
||
c.set_transform(moved.view);
|
||
c.push_layer(None, None, None, None, Some(filter));
|
||
me.draw_body(c, &moved, it, bp, xf, page);
|
||
if grow > 0.0 && solid {
|
||
c.set_transform(moved.view * xf);
|
||
c.set_paint(peniko::Color::BLACK);
|
||
c.set_stroke(kurbo::Stroke::new(grow * 2.0).with_join(kurbo::Join::Round));
|
||
c.stroke_path(bp);
|
||
}
|
||
c.pop_layer();
|
||
});
|
||
}
|
||
// Gradient feather: the object (feathered or not) drawn in its own layer, then kept as
|
||
// much as the opacity gradient says.
|
||
let gf = &e.gradient_feather;
|
||
let directional = e.directional_feather.on && e.directional_feather.widths.iter().any(|w| *w > 0.0);
|
||
if gf.on || directional {
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
ctx.push_layer(None, None, None, None, None);
|
||
}
|
||
// The object, feathered or not. Choke keeps that much of the width opaque (a shorter
|
||
// fade); Sharp and Rounded corners follow the inset shape more tightly than Diffused.
|
||
if e.feather > 0.0 && it.path.is_closed() {
|
||
let fo = &e.feather_options;
|
||
let w = e.feather * (1.0 - fo.choke.clamp(0.0, 100.0) / 100.0).max(0.01);
|
||
let (sigma, join) = match fo.corners {
|
||
designcraft_doc::FeatherCorners::Diffused => (w / 4.0, kurbo::Join::Round),
|
||
designcraft_doc::FeatherCorners::Rounded => (w / 3.0, kurbo::Join::Round),
|
||
designcraft_doc::FeatherCorners::Sharp => (w / 6.0, kurbo::Join::Miter),
|
||
};
|
||
// The body and its clip stay at device resolution; only the blurred mask may be drawn
|
||
// offscreen at a lower one.
|
||
ctx.set_transform(f.view * xf);
|
||
ctx.push_clip_layer(bp);
|
||
self.draw_body(ctx, f, it, bp, xf, page);
|
||
// Keep the body where the blurred inset silhouette is.
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
|
||
self.with_filters(ctx, f, reach, sigma, None, |_, c, fr| {
|
||
c.set_transform(fr.view);
|
||
c.push_layer(None, None, None, None, Some(blur(sigma)));
|
||
c.set_transform(fr.view * xf);
|
||
c.set_paint(peniko::Color::BLACK);
|
||
c.fill_path(bp);
|
||
c.set_transform(Affine::IDENTITY);
|
||
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
|
||
c.set_transform(fr.view * xf);
|
||
c.set_stroke(kurbo::Stroke::new(w).with_join(join));
|
||
c.stroke_path(bp);
|
||
c.pop_layer();
|
||
c.pop_layer();
|
||
});
|
||
ctx.pop_layer();
|
||
ctx.pop_layer();
|
||
} else {
|
||
self.draw_body(ctx, f, it, bp, xf, page);
|
||
}
|
||
// Directional feather: one fading ramp per side, multiplied in.
|
||
let df = &e.directional_feather;
|
||
if df.on && df.widths.iter().any(|w| *w > 0.0) {
|
||
let b = it.inner_bounds();
|
||
let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { b };
|
||
let ramps = [
|
||
(df.widths[0], kurbo::Point::new(b.x0, b.y0), kurbo::Point::new(b.x0, b.y0 + df.widths[0])),
|
||
(df.widths[1], kurbo::Point::new(b.x0, b.y0), kurbo::Point::new(b.x0 + df.widths[1], b.y0)),
|
||
(df.widths[2], kurbo::Point::new(b.x0, b.y1), kurbo::Point::new(b.x0, b.y1 - df.widths[2])),
|
||
(df.widths[3], kurbo::Point::new(b.x1, b.y0), kurbo::Point::new(b.x1 - df.widths[3], b.y0)),
|
||
];
|
||
for (w, p0, p1) in ramps {
|
||
if w <= 0.0 {
|
||
continue;
|
||
}
|
||
let stops = [
|
||
peniko::ColorStop::from((0.0, peniko::Color::from_rgba8(0, 0, 0, 0))),
|
||
peniko::ColorStop::from((1.0, peniko::Color::from_rgba8(0, 0, 0, 255))),
|
||
];
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
|
||
ctx.set_transform(f.view * xf);
|
||
ctx.set_paint(peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice()));
|
||
ctx.fill_rect(&area.inflate(4.0, 4.0));
|
||
ctx.reset_paint_transform();
|
||
ctx.pop_layer();
|
||
}
|
||
}
|
||
if gf.on {
|
||
gradient_feather_mask(ctx, f, it, xf);
|
||
}
|
||
if gf.on || directional {
|
||
ctx.pop_layer();
|
||
}
|
||
if !it.path.is_closed() {
|
||
return;
|
||
}
|
||
// Inner shadow, clipped to the shape.
|
||
if e.inner_shadow.on
|
||
&& let Some(c) = doc.resolve_color(&e.inner_shadow.color, 1.0)
|
||
{
|
||
let s = &e.inner_shadow;
|
||
let (sigma, choke) = split(s.size, s.choke);
|
||
let off = offset(doc.light_angle(s.angle, s.global_light), s.distance);
|
||
self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal);
|
||
}
|
||
// Inner glow: from the edges inwards (or out from the centre), screened over the object.
|
||
if e.inner_glow.on
|
||
&& let Some(c) = doc.resolve_color(&e.inner_glow.color, 1.0)
|
||
{
|
||
let g = &e.inner_glow;
|
||
let (sigma, choke) = split(g.size, g.choke);
|
||
// Precise keeps the glow close to the edge: a tighter blur.
|
||
let sigma = if g.technique == designcraft_doc::GlowTechnique::Precise { sigma * 0.4 } else { sigma };
|
||
let mix = crate::blend_mode(g.mode).mix;
|
||
let paint = color_of(&c, g.opacity.clamp(0.0, 1.0));
|
||
if g.center {
|
||
self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| {
|
||
c.set_transform(fr.view);
|
||
c.push_layer(None, None, None, None, Some(blur(sigma)));
|
||
c.set_transform(fr.view * xf);
|
||
c.set_paint(paint);
|
||
c.fill_path(bp);
|
||
// Fade towards the edge: cut a band of the glow's size away.
|
||
c.set_transform(Affine::IDENTITY);
|
||
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
|
||
c.set_transform(fr.view * xf);
|
||
c.set_paint(peniko::Color::BLACK);
|
||
c.set_stroke(kurbo::Stroke::new((g.size - choke).max(0.5) * 2.0).with_join(kurbo::Join::Round));
|
||
c.stroke_path(bp);
|
||
c.pop_layer();
|
||
c.pop_layer();
|
||
});
|
||
} else {
|
||
self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix);
|
||
}
|
||
}
|
||
// Satin: the shape shifted both ways along the angle, blurred, where exactly one covers.
|
||
if e.satin.on
|
||
&& let Some(c) = doc.resolve_color(&e.satin.color, 1.0)
|
||
{
|
||
let st = &e.satin;
|
||
let off = offset(st.angle, st.distance / 2.0);
|
||
let sigma = st.size.max(0.0) / 2.0;
|
||
let paint = color_of(&c, st.opacity.clamp(0.0, 1.0));
|
||
let invert = st.invert;
|
||
self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |_, c, fr| {
|
||
c.set_transform(fr.view);
|
||
c.push_layer(None, None, None, None, Some(blur(sigma)));
|
||
if invert {
|
||
c.set_transform(fr.view * xf);
|
||
c.set_paint(paint);
|
||
c.fill_path(bp);
|
||
}
|
||
c.set_transform(Affine::IDENTITY);
|
||
c.push_layer(None, Some(BlendMode::new(Mix::Normal, if invert { Compose::DestOut } else { Compose::SrcOver })), None, None, None);
|
||
c.set_transform(fr.view * Affine::translate(off) * xf);
|
||
c.set_paint(paint);
|
||
c.fill_path(bp);
|
||
c.set_transform(Affine::IDENTITY);
|
||
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Xor)), None, None, None);
|
||
c.set_transform(fr.view * Affine::translate(-off) * xf);
|
||
c.fill_path(bp);
|
||
c.pop_layer();
|
||
c.pop_layer();
|
||
c.pop_layer();
|
||
});
|
||
}
|
||
// Bevel and Emboss (inner bevel): highlight from the lit side, shadow from the other.
|
||
if e.bevel.on {
|
||
let b = &e.bevel;
|
||
let off = offset(doc.light_angle(b.angle, b.global_light), b.size * (b.depth / 100.0).clamp(0.01, 10.0) * 0.5);
|
||
let sigma = b.size.max(0.0) / 2.0;
|
||
if let Some(c) = doc.resolve_color(&b.highlight, 1.0) {
|
||
// The inverse shape moved away from the light lights the edges facing it.
|
||
self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)), Mix::Screen);
|
||
}
|
||
if let Some(c) = doc.resolve_color(&b.shadow, 1.0) {
|
||
self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)), Mix::Multiply);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// An inner shadow-like edge: everything outside the shape moved by `off`, blurred by
|
||
/// `sigma`, clipped to the shape and blended with `mix`.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn inner_edge(
|
||
&mut self,
|
||
ctx: &mut RenderContext,
|
||
f: &Frame,
|
||
reach: Rect,
|
||
bp: &BezPath,
|
||
xf: Affine,
|
||
off: Vec2,
|
||
sigma: f64,
|
||
choke: f64,
|
||
paint: peniko::Color,
|
||
mix: Mix,
|
||
) {
|
||
self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| {
|
||
c.set_transform(fr.view);
|
||
c.push_layer(None, None, None, None, Some(blur(sigma)));
|
||
// Everything outside the offset shape, so the colour bleeds in from the edges.
|
||
let m = Affine::translate(off) * xf;
|
||
let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px;
|
||
let mut inv = m.transform_rect_bbox(bp.bounding_box()).inflate(pad, pad).to_path(0.1);
|
||
let mut shape = bp.clone();
|
||
shape.apply_affine(m);
|
||
inv.extend(shape.iter());
|
||
c.set_paint(paint);
|
||
c.set_fill_rule(peniko::Fill::EvenOdd);
|
||
c.fill_path(&inv);
|
||
c.set_fill_rule(peniko::Fill::NonZero);
|
||
if choke > 0.0 {
|
||
c.set_stroke(kurbo::Stroke::new(choke * 2.0).with_join(kurbo::Join::Round));
|
||
c.stroke_path(&shape);
|
||
}
|
||
c.pop_layer();
|
||
});
|
||
}
|
||
|
||
/// [`Renderer::with_filters`] clipped to `clip` (a path and its spread transform), blended with
|
||
/// `mix`. The effect's blend must see the object's backdrop, outside its clipping and offscreen
|
||
/// filter layers. Putting it inside the clip blends with transparent pixels instead.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn with_blended_filters(
|
||
&mut self,
|
||
ctx: &mut RenderContext,
|
||
f: &Frame,
|
||
reach: Rect,
|
||
sigma: f64,
|
||
mix: Mix,
|
||
clip: (&BezPath, Affine),
|
||
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),
|
||
) {
|
||
if mix != Mix::Normal {
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None);
|
||
}
|
||
self.with_filters(ctx, f, reach, sigma, Some(clip), draw);
|
||
if mix != Mix::Normal {
|
||
ctx.pop_layer();
|
||
}
|
||
}
|
||
|
||
/// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a
|
||
/// multithreaded context, or for a blur wider than [`MAX_BLUR_SPREAD`], the layer is drawn into
|
||
/// an offscreen single-threaded context covering `reach` (spread space) plus the blur's spread,
|
||
/// then composited back. `clip` (a path and its spread transform) clips what `draw` paints;
|
||
/// at a lower resolution it clips the composited image on `ctx`, so its edge stays sharp.
|
||
pub(crate) fn with_filters(
|
||
&mut self,
|
||
ctx: &mut RenderContext,
|
||
f: &Frame,
|
||
reach: Rect,
|
||
sigma: f64,
|
||
clip: Option<(&BezPath, Affine)>,
|
||
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),
|
||
) {
|
||
// 3σ of the Gaussian in pixels, plus a pixel of antialiasing.
|
||
let spread = sigma.max(0.0) * 3.0 / f.px + 2.0;
|
||
if !f.mt && spread <= MAX_BLUR_SPREAD {
|
||
push_clip(ctx, f, clip);
|
||
draw(self, ctx, f);
|
||
pop_clip(ctx, clip);
|
||
return;
|
||
}
|
||
let Some(o) = offscreen(f.view.transform_rect_bbox(reach), spread, ctx.width(), ctx.height()) else { return };
|
||
let mut off = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||
let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -o.y0)) * f.view;
|
||
let shifted = Frame { mt: false, view, px: f.px * o.scale, ..*f };
|
||
// Clip where the result is at full resolution: offscreen unless that is reduced.
|
||
let (inner, outer) = if o.scale > 1.0 { (None, clip) } else { (clip, None) };
|
||
push_clip(&mut off, &shifted, inner);
|
||
draw(self, &mut off, &shifted);
|
||
pop_clip(&mut off, inner);
|
||
off.flush();
|
||
let mut pm = vello_cpu::Pixmap::new(o.width, o.height);
|
||
off.render(&mut pm, &mut self.resources);
|
||
push_clip(ctx, f, outer);
|
||
ctx.set_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale));
|
||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(pm)), sampler: peniko::ImageSampler::default() });
|
||
ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(o.width), f64::from(o.height)));
|
||
pop_clip(ctx, outer);
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
}
|
||
}
|
||
|
||
fn push_clip(ctx: &mut RenderContext, f: &Frame, clip: Option<(&BezPath, Affine)>) {
|
||
if let Some((bp, xf)) = clip {
|
||
ctx.set_transform(f.view * xf);
|
||
ctx.push_clip_layer(bp);
|
||
}
|
||
}
|
||
|
||
fn pop_clip(ctx: &mut RenderContext, clip: Option<(&BezPath, Affine)>) {
|
||
if clip.is_some() {
|
||
ctx.pop_layer();
|
||
}
|
||
}
|
||
|
||
/// The widest blur (3σ, device pixels) drawn at full resolution. vello pads a filter layer by the
|
||
/// blur's width on every side, so a wide blur needs gigabytes; it is drawn at a lower resolution
|
||
/// instead, where it is this wide, and has no detail to lose there.
|
||
const MAX_BLUR_SPREAD: f64 = 1024.0;
|
||
|
||
/// An offscreen image for [`Renderer::with_filters`]: where it goes on the device, how many device
|
||
/// pixels one of its pixels covers, and its size.
|
||
#[derive(Debug, PartialEq)]
|
||
struct Offscreen {
|
||
x0: f64,
|
||
y0: f64,
|
||
scale: f64,
|
||
width: u16,
|
||
height: u16,
|
||
}
|
||
|
||
/// The offscreen image an effect is drawn in: `reach` (device pixels) widened by the blur's
|
||
/// `spread`, within the window widened the same way, at a resolution that keeps the blur within
|
||
/// [`MAX_BLUR_SPREAD`], at most `MAX_SIDE` a side. `None` when none of it shows.
|
||
fn offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option<Offscreen> {
|
||
let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread, spread);
|
||
let r = reach.inflate(spread, spread).intersect(screen);
|
||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||
let scale = (spread / MAX_BLUR_SPREAD).max(1.0);
|
||
let side = |len: f64| (len / scale).ceil().min(crate::MAX_SIDE as f64);
|
||
let (w, h) = (side(r.x1.ceil() - x0), side(r.y1.ceil() - y0));
|
||
(w >= 1.0 && h >= 1.0).then_some(Offscreen { x0, y0, scale, width: w as u16, height: h as u16 })
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn a_wide_blur_is_drawn_offscreen_at_a_lower_resolution() {
|
||
let object = Rect::new(150.0, 150.0, 350.0, 350.0);
|
||
let small = Offscreen { x0: 140.0, y0: 140.0, scale: 1.0, width: 220, height: 220 };
|
||
assert_eq!(offscreen(object, 10.0, 1440, 900), Some(small));
|
||
// A 10,000 pt drop shadow at 100%: 3σ is 15,000 px, which asked for 30,204 px a side and as
|
||
// much padding again inside vello's filter layer. Drawn at 1/14.65 it is 1,024 px wide.
|
||
let wide = Offscreen { x0: -14852.0, y0: -14852.0, scale: 15_002.0 / 1024.0, width: 2062, height: 2062 };
|
||
assert_eq!(offscreen(object, 15_002.0, 1440, 900), Some(wide));
|
||
}
|
||
}
|