M4.1: .drawcraft format v2 — default-valued fields omitted, anchors as {p: [x, y], in?, out?, kind?}; large documents saved compact. 50k paths: 56 → 35 MB, open 722 → 244 ms; v1 files still load

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 08:39:58 -04:00
1 parent b318a62edd
commit 66855f309d
9 files changed
+147 -28

No files matched your search

+1 -1
View File
@@ -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 `<mask>` 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 |
+17 -11
View File
@@ -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<Effect>,
@@ -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<Dash>,
@@ -162,18 +162,18 @@ pub struct StrokeLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_arrow: Option<Arrowhead>,
/// 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<WidthProfile>,
/// Brush applied to the stroke (by brush name).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brush: Option<String>,
#[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<Effect>,
@@ -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 {
+13
View File
@@ -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<T: Default + PartialEq>(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;
+10 -10
View File
@@ -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<LiveShape>,
/// 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<String>,
#[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")]
+2 -2
View File
@@ -76,7 +76,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
.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<Value> {
pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> Result<Vec<u8>> {
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" => {
+12 -1
View File
@@ -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<u8> {
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<u8> {
let objects: usize = doc.layers.iter().map(|l| l.count()).sum();
save(doc, objects <= PRETTY_MAX_OBJECTS)
}
pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
let f: File = serde_json::from_slice(bytes).map_err(|e| FormatError::NotDrawcraft(e.to_string()))?;
if f.format != "drawcraft" {
+89
View File
@@ -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<WirePoint>,
#[serde(rename = "out", default, skip_serializing_if = "Option::is_none")]
h_out: Option<WirePoint>,
#[serde(default, skip_serializing_if = "is_corner")]
kind: AnchorKind,
}
fn is_corner(k: &AnchorKind) -> bool {
*k == AnchorKind::Corner
}
impl From<AnchorRepr> 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<Anchor> 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<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
[self.0.x, self.0.y].serialize(s)
}
}
impl<'de> Deserialize<'de> for WirePoint {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
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<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<WirePoint, A::Error> {
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<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<WirePoint, A::Error> {
let (mut x, mut y) = (None, None);
while let Some(k) = a.next_key::<std::borrow::Cow<'de, str>>()? {
match k.as_ref() {
"x" => x = Some(a.next_value()?),
"y" => y = Some(a.next_value()?),
_ => {
a.next_value::<serde::de::IgnoredAny>()?;
}
}
}
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::<Anchor>(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::<Anchor>(v1_corner).unwrap(), corner);
}
#[test]
fn square_bounds_and_area() {
let p = square();
+2 -2
View File
@@ -245,7 +245,7 @@ impl Headless {
pub fn save(&mut self, p: &Value) -> Result<Value, String> {
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")});
+1 -1
View File
@@ -157,7 +157,7 @@ pub fn save(app: &mut DrawcraftApp, path: Option<String>, 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());