mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
fix(render): radial gradients stretch with the object's box, as in InDesign
InDesign draws a radial gradient in the object's bounding box: on a non-square frame it's an ellipse reaching the frame's sides, and a Gradient Swatch tool drag keeps those proportions. LYH Studio episode 6 (3:16) gives the left page's background a radial gradient: magenta in the middle, cyan out to every edge. DesignCraft drew a circle reaching only the longer side, so a portrait page stayed purple at its sides. - doc: `Fill::radial_ellipse(bounds)`: centre and radii (default: the box's centre and half its width and height; with a tool vector, its start, and its length measured with the box made square). - render: the radial paint is that circle, stretched by the paint transform; PDF: the shading's transform does the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e862e0a613
commit
7a30e5427e
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-13** · Radial gradients stretch with the object's box, as InDesign draws them: by default an ellipse reaching the frame's sides (it was a circle reaching only the longer side), and a Gradient Swatch tool drag keeps the box's proportions; on screen and in PDF. No readiness percentage change.
|
||||
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
|
||||
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
|
||||
|
||||
@@ -32,6 +32,22 @@ impl Default for Fill {
|
||||
}
|
||||
|
||||
impl Fill {
|
||||
/// A radial gradient's ellipse on an object whose bounds (in its own space) are `b`: centre and
|
||||
/// horizontal and vertical radii. As in InDesign, a radial gradient stretches with the object's
|
||||
/// box: by default it's centred and reaches the box's sides (an ellipse in a non-square box);
|
||||
/// with a Gradient Swatch tool vector it's centred at the vector's start, and the vector's
|
||||
/// length, measured with the box made square, sets its size.
|
||||
pub fn radial_ellipse(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, f64, f64) {
|
||||
let k = if b.width() > 1e-9 && b.height() > 1e-9 { b.height() / b.width() } else { 1.0 };
|
||||
match self.gradient_vector {
|
||||
Some([x0, y0, x1, y1]) => {
|
||||
let r = designcraft_geom::Vec2::new(x1 - x0, (y1 - y0) / k).hypot();
|
||||
(designcraft_geom::Point::new(x0, y0), r, r * k)
|
||||
}
|
||||
None => (b.center(), b.width().max(1e-9) / 2.0, b.height().max(1e-9) / 2.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn none() -> Self {
|
||||
Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false }
|
||||
}
|
||||
|
||||
@@ -1751,3 +1751,40 @@ mod sample_color_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod radial_gradient_tests {
|
||||
use serde_json::json;
|
||||
|
||||
/// White at the side and the top of a 100×200 radial (black → white) rectangle at (50, 20) on
|
||||
/// a 200×240 page, rendered at 1 px/pt.
|
||||
fn sides(img: &designcraft_render::Rendered) -> (u8, u8, u8) {
|
||||
(img.pixel(100, 120)[0], img.pixel(52, 120)[0], img.pixel(100, 22)[0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radial_gradient_is_the_objects_ellipse_on_screen_and_in_pdf() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"rect": [50, 20, 150, 220], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.stroke", &json!({"ids": [id], "swatch": "[None]"})).unwrap();
|
||||
s.execute(
|
||||
"object.gradient",
|
||||
&json!({"ids": [id], "kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let mut r = designcraft_render::Renderer::new();
|
||||
let (c, side, top) = sides(&r.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap());
|
||||
assert!(c < 30 && side > 220 && top > 220, "screen: centre {c}, side {side}, top {top}");
|
||||
// The PDF, placed back and drawn: the same ellipse.
|
||||
let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap();
|
||||
let mut p = crate::Session::new();
|
||||
p.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap();
|
||||
p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "radial.pdf", "x": 0, "y": 0, "width": 200}))
|
||||
.unwrap();
|
||||
let d = p.doc().unwrap().doc.clone();
|
||||
let (c, side, top) = sides(&r.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap());
|
||||
assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1015,11 +1015,10 @@ impl Exporter<'_> {
|
||||
let v = fill.gradient_vector;
|
||||
return Some(match kind {
|
||||
GradientKind::Radial => {
|
||||
let (c, r) = match v {
|
||||
Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), Vec2::new(x1 - x0, y1 - y0).hypot()),
|
||||
None => (c, bounds.width().max(bounds.height()) / 2.0),
|
||||
};
|
||||
let r = r.max(1e-3) as f32;
|
||||
// A circle stretched into the object's ellipse, as on screen.
|
||||
let (c, rx, ry) = fill.radial_ellipse(bounds);
|
||||
let rx = rx.max(1e-3);
|
||||
let stretch = Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2());
|
||||
let (cx, cy) = (c.x as f32, c.y as f32);
|
||||
RadialGradient {
|
||||
fx: cx,
|
||||
@@ -1027,8 +1026,8 @@ impl Exporter<'_> {
|
||||
fr: 0.0,
|
||||
cx,
|
||||
cy,
|
||||
cr: r,
|
||||
transform: Transform::identity(),
|
||||
cr: rx as f32,
|
||||
transform: tf(stretch),
|
||||
spread_method: SpreadMethod::Pad,
|
||||
stops,
|
||||
anti_alias: false,
|
||||
|
||||
@@ -886,15 +886,14 @@ fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_do
|
||||
}
|
||||
let c = bounds.center();
|
||||
let grad = match (g.kind, fill.gradient_vector) {
|
||||
(designcraft_color::GradientKind::Radial, Some([x0, y0, x1, y1])) => {
|
||||
let r = Vec2::new(x1 - x0, y1 - y0).hypot().max(1e-3);
|
||||
peniko::Gradient::new_radial((x0, y0), r as f32).with_stops(stops.as_slice())
|
||||
// Radial: a circle stretched into the object's ellipse (the paint transform).
|
||||
(designcraft_color::GradientKind::Radial, _) => {
|
||||
let (c, rx, ry) = fill.radial_ellipse(bounds);
|
||||
let rx = rx.max(1e-3);
|
||||
ctx.set_paint_transform(Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2()));
|
||||
peniko::Gradient::new_radial(c, rx as f32).with_stops(stops.as_slice())
|
||||
}
|
||||
(_, Some([x0, y0, x1, y1])) => peniko::Gradient::new_linear((x0, y0), (x1, y1)).with_stops(stops.as_slice()),
|
||||
(designcraft_color::GradientKind::Radial, None) => {
|
||||
let r = bounds.width().max(bounds.height()) / 2.0;
|
||||
peniko::Gradient::new_radial(c, r as f32).with_stops(stops.as_slice())
|
||||
}
|
||||
_ => {
|
||||
let a = angle.unwrap_or(0.0).to_radians();
|
||||
let half = (bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Gradient fills: InDesign stretches a radial gradient with the object's box.
|
||||
|
||||
use designcraft_color::swatch::{Swatch, SwatchValue};
|
||||
use designcraft_color::{Color, Gradient, GradientKind, GradientStop};
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef};
|
||||
use designcraft_geom::{Affine, Rect, Vec2, shapes};
|
||||
use designcraft_render::{Placed, RenderOptions, Rendered, Renderer};
|
||||
|
||||
/// A 100×200 rectangle at (50, 20) filled with a black-to-white radial gradient.
|
||||
fn portrait(vector: Option<[f64; 4]>) -> Document {
|
||||
let mut d = Document::new(&NewDocument { width: 200.0, height: 240.0, facing_pages: false, ..Default::default() });
|
||||
let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 };
|
||||
d.swatches.push(Swatch {
|
||||
name: "Radial".into(),
|
||||
value: SwatchValue::Gradient {
|
||||
gradient: Gradient { kind: GradientKind::Radial, stops: vec![stop(0.0, Color::BLACK), stop(1.0, Color::WHITE)] },
|
||||
},
|
||||
locked: false,
|
||||
named: true,
|
||||
hidden: false,
|
||||
});
|
||||
let id = ItemId(d.alloc());
|
||||
let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(50.0, 20.0, 150.0, 220.0)));
|
||||
it.fill = Fill { swatch: "Radial".into(), gradient_vector: vector, ..Fill::none() };
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn render(d: &Document) -> Rendered {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 0;
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
r.render(d, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), xf: Affine::translate(Vec2::ZERO) }], 200, 240, Affine::IDENTITY, &opts)
|
||||
}
|
||||
|
||||
fn grey(img: &Rendered, x: u32, y: u32) -> u8 {
|
||||
img.pixel(x, y)[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radial_gradient_stretches_with_its_object() {
|
||||
// Centred, it reaches white at the sides and at the top and bottom alike (an ellipse).
|
||||
let img = render(&portrait(None));
|
||||
assert!(grey(&img, 100, 120) < 20, "black at the centre: {}", grey(&img, 100, 120));
|
||||
let (side, top) = (grey(&img, 52, 120), grey(&img, 100, 22));
|
||||
assert!(side > 225 && top > 225, "white at the side ({side}) and the top ({top})");
|
||||
// Half-way to the side and half-way to the top: the same grey.
|
||||
let (hs, ht) = (grey(&img, 75, 120), grey(&img, 100, 70));
|
||||
assert!(hs.abs_diff(ht) < 12, "half-way: side {hs}, top {ht}");
|
||||
// A Gradient Swatch tool vector to the right side: the same ellipse.
|
||||
let dragged = render(&portrait(Some([100.0, 120.0, 150.0, 120.0])));
|
||||
assert!(grey(&dragged, 100, 70).abs_diff(ht) < 12 && grey(&dragged, 75, 120).abs_diff(hs) < 12);
|
||||
}
|
||||
@@ -421,7 +421,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
|
||||
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
|
||||
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
|
||||
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML.
|
||||
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF).
|
||||
- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup.
|
||||
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.
|
||||
- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
|
||||
|
||||
Reference in New Issue
Block a user