Merge main into #2331

This commit is contained in:
bflatastic
2026-11-13 23:06:07 +07:00
6 changed files with 109 additions and 15 deletions
+2 -1
View File
@@ -142,7 +142,8 @@ message StyleId GUID guid=1
message VariableID GUID guid=1
message VariableSetID GUID guid=1
message ColorStopVar Color color=1, VariableData colorVar=2, float position=3
message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12, VariableData colorVar=21, ColorStopVar[] stopsVar=23
message PaintFilterMessage float tint=1, float shadows=2, float highlights=3, float exposure=5, float temperature=7, float vibrance=8, float contrast=9
message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12, PaintFilterMessage paintFilter=14, VariableData colorVar=21, ColorStopVar[] stopsVar=23
message Effect EffectType type=1, Color color=2, Vector offset=3, float radius=4, bool visible=5, BlendMode blendMode=6, float spread=7, bool showShadowBehindNode=8, BlurOpType blurOpType=19, float startRadius=21, Vector startOffset=20, Vector endOffset=28, NoiseType noiseType=26, Vector noiseSize=22, float density=25, float opacity=27, Color secondaryColor=24, bool clipToShape=23, VariableData radiusVar=9, VariableData colorVar=10, VariableData spreadVar=11, VariableData xVar=12, VariableData yVar=13, VariableData startRadiusVar=45
struct FontName string family, string style, string postscript
struct Number float value, NumberUnits units
+15 -2
View File
@@ -76,8 +76,21 @@ impl<'d> Exporter<'d> {
if let (ImageScaleMode::Crop, Some(c)) = (img.scale_mode, img.crop) {
f.push(("transform", matrix(Affine::new(c))));
}
if img.filters != Default::default() {
self.warn("image adjustments (exposure, contrast, …) were left out");
let x = img.filters.sanitized();
if !x.is_identity() {
// The file's tint is positive towards magenta, ours towards green; `0.0 -` keeps zero positive.
f.push((
"paintFilter",
o(vec![
("exposure", fl(f64::from(x.exposure))),
("contrast", fl(f64::from(x.contrast))),
("vibrance", fl(f64::from(x.saturation))),
("temperature", fl(f64::from(x.temperature))),
("tint", fl(0.0 - f64::from(x.tint))),
("highlights", fl(f64::from(x.highlights))),
("shadows", fl(f64::from(x.shadows))),
]),
));
}
}
PaintKind::Pattern(_) => {
+81 -3
View File
@@ -16,8 +16,8 @@ fn image_filter_schema() -> Schema {
Schema::parse(&text).unwrap()
}
#[test]
fn image_filters_import_from_current_container_and_skip_ambiguous_data() {
/// A file with one image rectangle per filter case (current container, legacy, both, neutral, unknown fields).
fn image_filter_fixture() -> Vec<u8> {
let filter = |fields: Vec<(&'static str, Kv<'static>)>| o(fields);
let image_paint = |id: u64, name: &'static str, mut extra: Vec<(&'static str, Kv<'static>)>| {
let mut paint = vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))];
@@ -54,7 +54,12 @@ fn image_filters_import_from_current_container_and_skip_ambiguous_data() {
vec![("paintFilter", filter(vec![("brightness", f(0.4)), ("detail", f(-0.2)), ("vignette", f(0.3)), ("mystery", f(0.1))]))],
),
];
let bytes = zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))])));
zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))])))
}
#[test]
fn image_filters_import_from_current_container_and_skip_ambiguous_data() {
let bytes = image_filter_fixture();
let (doc, warnings) = import(&bytes, "ImageFilters.fig").unwrap();
let get = |name: &str| {
let node = doc.nodes().find(|node| node.name == name).unwrap();
@@ -102,3 +107,76 @@ fn warnings_naming_unknown_filter_fields_are_capped() {
let named = warnings.iter().filter(|w| w.contains("unsupported image filter")).count();
assert!((100..=crate::rest::MAX_WARNINGS).contains(&named), "{named} of {} warnings", warnings.len());
}
#[test]
fn image_adjustments_survive_a_fig_export() {
let (mut doc, _) = import(&image_filter_fixture(), "ImageFilters.fig").unwrap();
let filters_of = |doc: &figcraft_doc::Document, name: &str| {
let node = doc.nodes().find(|node| node.name == name).unwrap();
let figcraft_doc::color::PaintKind::Image(image) = &node.fills[0].kind else { panic!("{name} is not an image fill") };
image.filters
};
// Values a document can hold and the file can't: out of range, and not a number.
let ids: Vec<figcraft_doc::NodeId> = doc.ids().collect();
for id in ids {
let Some(node) = doc.node_mut(id) else { continue };
let name = node.name.clone();
let Some(figcraft_doc::color::PaintKind::Image(image)) = node.fills.first_mut().map(|p| &mut p.kind) else { continue };
match name.as_str() {
"Legacy" => image.filters.exposure = 5.0,
"Explicit neutral" => image.filters.contrast = f32::NAN,
_ => {}
}
}
let (bytes, warnings) = export::export(&doc, None).unwrap();
assert!(!warnings.iter().any(|w| w.contains("image adjustments")), "{warnings:?}");
let (back, _) = import(&bytes, "Back.fig").unwrap();
for name in ["Current", "Negative tint", "Neutral"] {
let (a, b) = (filters_of(&doc, name), filters_of(&back, name));
for (x, y) in [
(a.exposure, b.exposure),
(a.contrast, b.contrast),
(a.saturation, b.saturation),
(a.temperature, b.temperature),
(a.tint, b.tint),
(a.highlights, b.highlights),
(a.shadows, b.shadows),
] {
assert!((x - y).abs() < 1e-6, "{name}: {a:?} came back as {b:?}");
}
}
assert!(filters_of(&back, "Neutral").is_identity());
assert!(!filters_of(&back, "Current").is_identity());
// What is written: the file's names and signs.
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let fill_of = |name: &str| {
let nc = msg.get("nodeChanges").unwrap().list().iter().find(|nc| nc.get("name").and_then(Kv::str) == Some(name)).unwrap();
nc.get("fillPaints").unwrap().list()[0].clone()
};
let current = fill_of("Current");
let written = |paint: &Kv, field: &str| paint.get("paintFilter").and_then(|f| f.get(field)).and_then(Kv::f64);
assert!((written(&current, "vibrance").unwrap() + 0.4).abs() < 1e-6);
assert!((written(&current, "tint").unwrap() - 1.0).abs() < 1e-6);
assert!((written(&current, "shadows").unwrap() - 0.7).abs() < 1e-6);
assert!(fill_of("Neutral").get("paintFilter").is_none());
assert!((written(&fill_of("Legacy"), "exposure").unwrap() - 1.0).abs() < 1e-6, "written within the file's range");
assert!(fill_of("Explicit neutral").get("paintFilter").is_none(), "not a number is no adjustment");
let partly = fill_of("Negative tint");
for field in ["exposure", "contrast", "vibrance", "temperature", "tint", "highlights", "shadows"] {
assert!(written(&partly, field).is_some(), "{field} is written");
}
assert!((written(&partly, "tint").unwrap() + 1.0).abs() < 1e-6);
// The numbers the format uses.
let s = Schema::parse(export::SCHEMA).unwrap();
let def = |n: &str| s.defs.iter().find(|d| d.name == n).unwrap();
let id = |d: &str, f: &str| def(d).fields.iter().find(|x| x.name == f).unwrap().value;
assert_eq!(id("Paint", "paintFilter"), 14);
assert_eq!(
["tint", "shadows", "highlights", "exposure", "temperature", "vibrance", "contrast"].map(|f| id("PaintFilterMessage", f)),
[1, 2, 3, 5, 7, 8, 9]
);
}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -126,7 +126,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| Dimension | Weight | Ready | Hours | Evidence | Doc |
|---|---|---|---|---|---|
| Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file |
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction not yet compared (move and resize modifiers compared in Figma's web app on 2026-11-08 and matching, Ctrl/Cmd not measured; hover states and popovers not compared) (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
| File formats | 12 | 61% | 5–10 | `.sketch` read (Sketch 48 and newer, best effort; 2026-10-21): live symbols, shared styles, Sketch 2025 frames and stacks, checked on three real files against Sketch's own renders (0.8–2% of pixels differ on average); `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) |
| Collaboration | 10 | 13% | 52–104 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) |
@@ -196,7 +196,7 @@ and is left out as an area, but its effect on working with Figma users is counte
| Discount | Factor | Evidence |
|---|---|---|
| Interaction fidelity | ×0.90 | UI3 look compared side by side (~65% by the builders); no tool-by-tool comparison of cursors, modifier keys while dragging, snapping thresholds or popovers yet; Ctrl to suspend snapping on macOS missing ([ui-parity](ui-parity.md)) |
| Interaction fidelity | ×0.90 | UI3 look compared side by side (~65% by the builders); no tool-by-tool comparison of cursors, snapping thresholds or popovers yet (move and resize modifiers were compared in Figma's web app on 2026-11-08 and match, Ctrl/Cmd not measured); Ctrl to suspend snapping on macOS missing ([ui-parity](ui-parity.md)) |
| Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way |
| File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) and writes an experimental one (written files open and draw in other software; effects, images and prototypes unchecked there); incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) |
+5 -4
View File
File diff suppressed because one or more lines are too long