diff --git a/ROADMAP.md b/ROADMAP.md index f09076dc..eab8720b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,7 +58,7 @@ _Last updated: 2026-10-01._ | M2 | Drawing tools + smart guides | โœ… mostly done (Shaper, Pen modifier nuances) | 5โ€“10 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ๐ŸŸก panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `` in/out + PDF soft mask); mask-editing mode, freeform gradients pending | 6โ€“10 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ๐ŸŸก Export for Screens (PNG/JPG/WebP/SVG/PDF ร— scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); PNG/PDF clipboard flavours, EPS/DXF pending | 8โ€“12 | -| M5 | Performance | ๐ŸŸก background render + caches + MT done; `drawcraft-cli bench`; raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10โ€“20 | +| M5 | Performance | ๐ŸŸก background render + caches + MT done; `drawcraft-cli bench` and `drawcraft-cli perf` (budget suite); file format v2 opens 3ร— faster (50k paths: 722 โ†’ 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10โ€“20 | | M6 | Path operations (Pathfinder, Shape Builder, offsetโ€ฆ) | โœ… mostly done (Shape Builder edge erase, large-offset bug open) | 3โ€“6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | ๐ŸŸก in progress | 20โ€“30 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ๐ŸŸก live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8โ€“12 | diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index eb7c25a2..a25079b0 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -126,11 +126,11 @@ fn one() -> f32 { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct FillLayer { pub paint: Paint, - #[serde(default = "one")] + #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] pub opacity: f32, - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub blend: BlendMode, - #[serde(default = "yes")] + #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] pub visible: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub effects: Vec, @@ -147,13 +147,13 @@ pub struct StrokeLayer { pub paint: Paint, /// Weight in points. pub width: f64, - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub cap: LineCap, - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub join: LineJoin, - #[serde(default = "ten")] + #[serde(default = "ten", skip_serializing_if = "is_ten")] pub miter_limit: f64, - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub align: StrokeAlign, #[serde(default, skip_serializing_if = "Option::is_none")] pub dash: Option, @@ -162,18 +162,18 @@ pub struct StrokeLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub end_arrow: Option, /// Arrowhead scale in percent (start, end). - #[serde(default = "hundreds")] + #[serde(default = "hundreds", skip_serializing_if = "is_hundreds")] pub arrow_scale: (f64, f64), #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, /// Brush applied to the stroke (by brush name). #[serde(default, skip_serializing_if = "Option::is_none")] pub brush: Option, - #[serde(default = "one")] + #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] pub opacity: f32, - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub blend: BlendMode, - #[serde(default = "yes")] + #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] pub visible: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub effects: Vec, @@ -185,6 +185,12 @@ fn ten() -> f64 { fn hundreds() -> (f64, f64) { (100.0, 100.0) } +fn is_ten(v: &f64) -> bool { + *v == 10.0 +} +fn is_hundreds(v: &(f64, f64)) -> bool { + *v == (100.0, 100.0) +} impl StrokeLayer { pub fn new(paint: Paint, width: f64) -> Self { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c3819c14..3d39aab9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -16,6 +16,19 @@ pub mod text; use std::collections::BTreeMap; use std::sync::Arc; +/// `skip_serializing_if` predicates: fields equal to their serde default are not written. +pub(crate) mod skip { + pub fn is_default(v: &T) -> bool { + *v == T::default() + } + pub fn is_true(v: &bool) -> bool { + *v + } + pub fn is_one(v: &f32) -> bool { + *v == 1.0 + } +} + pub use appearance::{Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; pub use drawcraft_color as color; pub use drawcraft_geom as geom; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index ed139d85..e3d3fbd3 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -183,15 +183,15 @@ pub enum NodeKind { }, Path { path: PathData, - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] rule: FillRule, #[serde(default, skip_serializing_if = "Option::is_none")] live: Option, /// This path acts as a clipping path inside a clip group. - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] clipping: bool, /// Guide (non-printing) path. - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] guide: bool, }, /// Compound path: children are paths painted as one with the compound's appearance. @@ -243,19 +243,19 @@ pub struct Node { pub id: NodeId, #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, - #[serde(default = "yes")] + #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] pub visible: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub locked: bool, - #[serde(default = "one")] + #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] pub opacity: f32, - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub blend: BlendMode, - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub isolate: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub knockout: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub appearance: Appearance, /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 92008d6c..06270361 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -76,7 +76,7 @@ fn save(s: &mut Session, p: &Value) -> Result { .map(str::to_string) .or_else(|| s.active().and_then(|d| d.path.clone())) .ok_or_else(|| bad("document.save", "no path"))?; - let bytes = drawcraft_format::save(&s.doc()?.doc, true); + let bytes = drawcraft_format::save_file(&s.doc()?.doc); std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let st = s.doc_mut()?; st.path = Some(path.clone()); @@ -87,7 +87,7 @@ fn save(s: &mut Session, p: &Value) -> Result { pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> Result> { let doc = &s.doc()?.doc; Ok(match format { - "drawcraft" => drawcraft_format::save(doc, true), + "drawcraft" => drawcraft_format::save_file(doc), "svg" => drawcraft_svg::export(doc, &drawcraft_svg::ExportOptions { artboard: Some(artboard), ..Default::default() }).into_bytes(), "pdf" => drawcraft_pdf::export(doc, &drawcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?, "png" | "jpg" | "jpeg" | "webp" => { diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1e4c58e6..6f3da4a2 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -16,7 +16,8 @@ use std::sync::Arc; use drawcraft_doc::{Document, ImageBlob}; use serde::{Deserialize, Serialize}; -pub const VERSION: u32 = 1; +/// v2: anchors as `{p: [x, y], in?, out?, kind?}` and default-valued fields omitted (v1 files still load). +pub const VERSION: u32 = 2; pub const EXTENSION: &str = "drawcraft"; #[derive(Debug, thiserror::Error)] @@ -59,6 +60,16 @@ pub fn save(doc: &Document, pretty: bool) -> Vec { if pretty { serde_json::to_vec_pretty(&f).unwrap_or_default() } else { serde_json::to_vec(&f).unwrap_or_default() } } +/// Documents up to this many objects are saved pretty-printed (readable, diffable); larger ones +/// compact, where indentation would multiply the file size and slow opening down. +pub const PRETTY_MAX_OBJECTS: usize = 2000; + +/// Serialize for saving to disk: pretty when small, compact when large. +pub fn save_file(doc: &Document) -> Vec { + let objects: usize = doc.layers.iter().map(|l| l.count()).sum(); + save(doc, objects <= PRETTY_MAX_OBJECTS) +} + pub fn load(bytes: &[u8]) -> Result { let f: File = serde_json::from_slice(bytes).map_err(|e| FormatError::NotDrawcraft(e.to_string()))?; if f.format != "drawcraft" { diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 2a23f9b5..2619421c 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -25,7 +25,9 @@ pub enum AnchorKind { } /// One anchor point with absolute handle positions. A handle equal to `p` means "no handle". +/// Serialized without handles that equal `p` and without the default `kind` (smaller files). #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(from = "AnchorRepr", into = "AnchorRepr")] pub struct Anchor { pub p: Point, #[serde(rename = "in")] @@ -36,6 +38,80 @@ pub struct Anchor { pub kind: AnchorKind, } +/// Wire form of [`Anchor`]: points as `[x, y]` (format v2; `{"x", "y"}` maps from v1 files are +/// still read), and absent handles mean "no handle" (equal to `p`). +#[derive(Serialize, Deserialize)] +struct AnchorRepr { + p: WirePoint, + #[serde(rename = "in", default, skip_serializing_if = "Option::is_none")] + h_in: Option, + #[serde(rename = "out", default, skip_serializing_if = "Option::is_none")] + h_out: Option, + #[serde(default, skip_serializing_if = "is_corner")] + kind: AnchorKind, +} + +fn is_corner(k: &AnchorKind) -> bool { + *k == AnchorKind::Corner +} + +impl From for Anchor { + fn from(r: AnchorRepr) -> Self { + let p = r.p.0; + Self { p, h_in: r.h_in.map_or(p, |h| h.0), h_out: r.h_out.map_or(p, |h| h.0), kind: r.kind } + } +} + +impl From for AnchorRepr { + fn from(a: Anchor) -> Self { + let handle = |h: Point| (h != a.p).then_some(WirePoint(h)); + Self { p: WirePoint(a.p), h_in: handle(a.h_in), h_out: handle(a.h_out), kind: a.kind } + } +} + +/// A point written as `[x, y]`, read from `[x, y]` or `{"x": โ€ฆ, "y": โ€ฆ}`. +struct WirePoint(Point); + +impl Serialize for WirePoint { + fn serialize(&self, s: S) -> Result { + [self.0.x, self.0.y].serialize(s) + } +} + +impl<'de> Deserialize<'de> for WirePoint { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = WirePoint; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a point as [x, y] or {\"x\": x, \"y\": y}") + } + fn visit_seq>(self, mut a: A) -> Result { + let x = a.next_element()?.ok_or_else(|| serde::de::Error::invalid_length(0, &self))?; + let y = a.next_element()?.ok_or_else(|| serde::de::Error::invalid_length(1, &self))?; + Ok(WirePoint(Point::new(x, y))) + } + fn visit_map>(self, mut a: A) -> Result { + let (mut x, mut y) = (None, None); + while let Some(k) = a.next_key::>()? { + match k.as_ref() { + "x" => x = Some(a.next_value()?), + "y" => y = Some(a.next_value()?), + _ => { + a.next_value::()?; + } + } + } + Ok(WirePoint(Point::new( + x.ok_or_else(|| serde::de::Error::missing_field("x"))?, + y.ok_or_else(|| serde::de::Error::missing_field("y"))?, + ))) + } + } + d.deserialize_any(V) + } +} + impl Anchor { /// A corner anchor without handles. pub fn corner(p: Point) -> Self { @@ -342,6 +418,19 @@ mod tests { PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true)) } + #[test] + fn anchors_serialize_compactly_and_read_v1_maps() { + let corner = Anchor::corner(Point::new(1.0, 2.0)); + assert_eq!(serde_json::to_string(&corner).unwrap(), r#"{"p":[1.0,2.0]}"#); + let smooth = Anchor { p: Point::new(1.0, 2.0), h_in: Point::new(0.0, 2.0), h_out: Point::new(2.0, 2.0), kind: AnchorKind::Smooth }; + let back: Anchor = serde_json::from_str(&serde_json::to_string(&smooth).unwrap()).unwrap(); + assert_eq!(back, smooth); + let v1 = r#"{"p":{"x":1.0,"y":2.0},"in":{"x":0.0,"y":2.0},"out":{"x":2.0,"y":2.0},"kind":"Smooth"}"#; + assert_eq!(serde_json::from_str::(v1).unwrap(), smooth); + let v1_corner = r#"{"p":{"x":1.0,"y":2.0},"in":{"x":1.0,"y":2.0},"out":{"x":1.0,"y":2.0},"kind":"Corner"}"#; + assert_eq!(serde_json::from_str::(v1_corner).unwrap(), corner); + } + #[test] fn square_bounds_and_area() { let p = square(); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 55ffd476..e0ea0b4f 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -245,7 +245,7 @@ impl Headless { pub fn save(&mut self, p: &Value) -> Result { let st = self.session.active().ok_or("no document")?; let path = s(p, "path").map(str::to_string).or_else(|| st.path.clone()).ok_or("missing `path` (document was never saved)")?; - let bytes = drawcraft_format::save(&st.doc, true); + let bytes = drawcraft_format::save_file(&st.doc); std::fs::write(&path, bytes).map_err(|e| format!("write {path}: {e}"))?; if let Some(st) = self.session.active_mut() { st.path = Some(path.clone()); @@ -271,7 +271,7 @@ impl Headless { let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0); self.renderer.render_region(&doc, r, scale, false).to_png() } - "drawcraft" => drawcraft_format::save(&doc, true), + "drawcraft" => drawcraft_format::save_file(&doc), // pdf, jpg, webpโ€ฆ: the engine's exporter (same bytes as the app). other => { let params = json!({"path": path, "format": other, "scale": p.get("scale"), "artboard": p.get("artboard")}); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 0869b1f9..05500db5 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -157,7 +157,7 @@ pub fn save(app: &mut DrawcraftApp, path: Option, save_as: bool) -> Resu app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")? } }; - let bytes = drawcraft_format::save(&app.session.active().unwrap().doc, true); + let bytes = drawcraft_format::save_file(&app.session.active().unwrap().doc); write_out(app, &path, &bytes)?; if let Some(st) = app.session.active_mut() { st.path = Some(path.clone());