Distortion tools (Width, Liquify, Puppet Warp, Perspective Grid), Patterns + Repeat, colour management (CMS, proofing, separations), Preferences/Shortcuts/Workspaces; original brand mark; fix filter panic in thumbnails; README demo scenes

- New original DrawCraft mark (gradient + pen nib) replaces the two-letter badge
- render: thumbnails and pattern tiles use a single-threaded context (vello's default is
  multithreaded and panics on filters); per-item raster effects detected
- Type tool state no longer leaks across documents
- ui.set {status} for clean screenshots
- README: new hero, feature shots, Made in DrawCraft gallery; examples/neon-drive, ribbons,
  feature-sheet; ASSETS.md updated; heading renamed to Crafting Apps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-09-30 22:44:39 -04:00
co-authored by Claude Opus 5.5
parent 05f5bed1ad
commit acb2566d89
46 changed files with 24072 additions and 88 deletions
+33
View File
@@ -271,6 +271,39 @@ mod tests {
const WHITE: u32 = 765;
#[test]
fn thumbnails_of_glowing_objects_render_without_panicking() {
// Regression: thumbnails used vello's default (multithreaded) context, which panics on filters.
let d = doc_with(
Rect::new(20.0, 20.0, 60.0, 60.0),
Color::BLACK,
vec![("stylize.outerGlow", json!({"color": "#ff0000", "opacity": 100, "blur": 6}))],
);
let id = d.layers[0].children().unwrap()[0].id;
let t = Renderer::new().render_thumbnail(&d, id, 48).unwrap();
assert_eq!((t.width, t.height), (48, 48));
}
#[test]
fn per_stroke_raster_effects_use_the_single_threaded_pipeline() {
let mut d = doc_with(Rect::new(20.0, 20.0, 60.0, 60.0), Color::BLACK, vec![]);
let l = d.layers[0].id;
let id = d.layers[0].children().unwrap()[0].id;
let mut n = (*d.node(id).unwrap()).clone();
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
if let Some(drawcraft_doc::AppearanceItem::Stroke(s)) =
n.appearance.items.iter_mut().find(|i| matches!(i, drawcraft_doc::AppearanceItem::Stroke(_)))
{
s.effects =
vec![Effect { id: "stylize.outerGlow".into(), params: json!({"color": "#ff0000", "opacity": 100, "blur": 6}), visible: true }];
}
d.remove(id).unwrap();
d.insert(Some(l), 0, n).unwrap();
let mut r = Renderer::new();
r.threads = 4;
let _ = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
}
#[test]
fn drop_shadow_darkens_outside_the_shape() {
let r = Rect::new(20.0, 20.0, 60.0, 60.0);
+18 -2
View File
@@ -201,7 +201,7 @@ impl Renderer {
// Raster filters (drop shadow, glows, blur) need the single-threaded pipeline.
let mut has_filters = false;
doc.walk(|n| {
if !has_filters && !n.appearance.effects.is_empty() && !drawcraft_effects::raster_effects(&n.appearance.effects).is_empty() {
if !has_filters && node_has_raster_fx(n) {
has_filters = true;
}
});
@@ -273,7 +273,7 @@ impl Renderer {
let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6);
let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
let w = size.clamp(1, u16::MAX as u32) as u16;
let mut ctx = RenderContext::new(w, w);
let mut ctx = single_threaded_context(w, w);
let frame = Frame { doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() };
self.draw_node(&mut ctx, &frame, n, true);
ctx.flush();
@@ -694,6 +694,22 @@ impl Renderer {
}
/// Number of visible painted fill/stroke items (opacity folding is exact only for one).
/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a
/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur).
pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext {
RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() })
}
/// Whether drawing `n` itself may push a raster filter (node-level or per fill/stroke effects).
fn node_has_raster_fx(n: &Node) -> bool {
let raster = |e: &[_]| !e.is_empty() && !drawcraft_effects::raster_effects(e).is_empty();
raster(&n.appearance.effects)
|| n.appearance.items.iter().any(|i| match i {
AppearanceItem::Fill(f) => raster(&f.effects),
AppearanceItem::Stroke(s) => raster(&s.effects),
})
}
fn painted_items(n: &Node) -> usize {
n.appearance
.items
+1 -1
View File
@@ -61,7 +61,7 @@ pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap.
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16) -> Pixmap {
let (w, h) = (w.max(1), h.max(1));
let mut ctx = RenderContext::new(w, h);
let mut ctx = crate::single_threaded_context(w, h);
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
* Affine::translate(-region.origin().to_vec2());
let opts = RenderOptions::default();
+1 -5
View File
@@ -143,11 +143,7 @@ pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Option<
/// Ids marked Overprint Black.
pub fn overprint_ids(doc: &Document) -> Vec<NodeId> {
doc.unknown
.get(OVERPRINT_KEY)
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_u64()).map(NodeId).collect())
.unwrap_or_default()
doc.unknown.get(OVERPRINT_KEY).and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_u64()).map(NodeId).collect()).unwrap_or_default()
}
/// 100% black ink with no other process ink.