feat(loader): mesh/shader fills + .pen→.op legacy normalize hardening + pen2op bin + design-library merge
Bumps jian submodule for mesh-gradient/shader PenFill variants. normalize_legacy_doc gains 8 .pen-compat repairs (stroke-fill string, gradient fill, icon/prompt nodes, $ref cornerRadius, single-object fill). New pen2op bin converts Pencil .pen→.op. library.rs merges a .lib.op's reusable masters+variables into a working doc.
This commit is contained in:
parent
89e5699e69
commit
bf4a741d8e
95
crates/op-pen-loader/src/bin/pen2op.rs
Normal file
95
crates/op-pen-loader/src/bin/pen2op.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! `pen2op` — convert a plaintext canonical `.pen` (or legacy `.op`) file into
|
||||
//! a current-schema `.op` file by routing it THROUGH the canonical
|
||||
//! `load_canonical` + `normalize_legacy_doc` path the desktop uses to open
|
||||
//! files. A naive rename does NOT work: the normalize step (version 2.x →
|
||||
//! rewrite, image nodes missing `src`, `fill`-as-string, etc.) is required for
|
||||
//! the file to load in current OpenPencil.
|
||||
//!
|
||||
//! Usage: `pen2op <in.pen> <out.op>`
|
||||
//!
|
||||
//! The output is `serde_json::to_string_pretty(&result.value)` — the same
|
||||
//! canonical-PenDocument serialize path `op-smoke` uses to dump `state.doc`.
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let (Some(in_path), Some(out_path)) = (args.next(), args.next()) else {
|
||||
eprintln!("usage: pen2op <in.pen> <out.op>");
|
||||
return ExitCode::from(2);
|
||||
};
|
||||
|
||||
let src = match std::fs::read_to_string(&in_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("[pen2op] read {in_path} failed: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
// Load THROUGH the canonical loader so the legacy-tolerance normalize runs.
|
||||
let loaded = match op_pen_loader::load_canonical(&src) {
|
||||
Ok(loaded) => loaded,
|
||||
Err(e) => {
|
||||
eprintln!("[pen2op] load_canonical({in_path}) failed: {e:?}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
if !loaded.warnings.is_empty() {
|
||||
eprintln!(
|
||||
"[pen2op] {in_path}: {} load warning(s): {:?}",
|
||||
loaded.warnings.len(),
|
||||
loaded.warnings
|
||||
);
|
||||
}
|
||||
|
||||
// Same serialize path as op-smoke's `state.doc` dump.
|
||||
let json = match serde_json::to_string_pretty(&loaded.value) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
eprintln!("[pen2op] serialize {in_path} failed: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = std::fs::write(&out_path, &json) {
|
||||
eprintln!("[pen2op] write {out_path} failed: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
// Node count: walk the serialized JSON counting objects bearing a `type`
|
||||
// key (matches how the loader's normalize pass identifies PenNodes).
|
||||
let node_count = serde_json::from_str::<serde_json::Value>(&json)
|
||||
.map(|v| count_typed_objects(&v))
|
||||
.unwrap_or(0);
|
||||
|
||||
println!(
|
||||
"OK {in_path} -> {out_path} nodes={node_count} version={}",
|
||||
loaded.value.version
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Count objects carrying a `"type"` key anywhere in the tree (iterative walk,
|
||||
/// no recursion, so deep trees don't blow the stack).
|
||||
fn count_typed_objects(root: &serde_json::Value) -> usize {
|
||||
use serde_json::Value;
|
||||
let mut count = 0usize;
|
||||
let mut stack = vec![root];
|
||||
while let Some(v) = stack.pop() {
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
if map.contains_key("type") {
|
||||
count += 1;
|
||||
}
|
||||
for child in map.values() {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => stack.extend(arr.iter()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
|
@ -24,15 +24,16 @@
|
|||
use jian_scene::layout_scene::NodeKind;
|
||||
use jian_scene::layout_scene::{
|
||||
stable_image_source_id, DropShadow, Effect, LayoutScene, SceneFillType, SceneGradient,
|
||||
SceneGradientStop, SceneImageFit, SceneNode, ScenePage, SceneStroke, SceneTextAlign,
|
||||
SceneTextRun, SceneTextVerticalAlign, SceneWidget, SceneWidgetOption,
|
||||
SceneGradientStop, SceneImageFit, SceneNode, ScenePage, SceneShader, SceneShaderUniform,
|
||||
SceneStroke, SceneTextAlign, SceneTextRun, SceneTextVerticalAlign, SceneWidget,
|
||||
SceneWidgetOption,
|
||||
};
|
||||
use op_editor_core::render_backend::Color;
|
||||
use op_editor_core::scene_vars::VariableTable;
|
||||
|
||||
use crate::editor_state_var_table;
|
||||
use crate::payload::{
|
||||
DocPayload, GradientPayload, GradientStopPayload, NodePayload, StrokePayload,
|
||||
DocPayload, GradientPayload, GradientStopPayload, NodePayload, ShaderPayload, StrokePayload,
|
||||
};
|
||||
|
||||
/// Build a paint-only [`LayoutScene`] from an editor state.
|
||||
|
|
@ -216,6 +217,10 @@ fn node_payload_to_scene(
|
|||
.gradient
|
||||
.as_ref()
|
||||
.map(|g| scale_gradient_opacity(payload_gradient_to_scene(g), cum_opacity)),
|
||||
shader: node
|
||||
.shader
|
||||
.as_ref()
|
||||
.map(|s| payload_shader_to_scene(s, cum_opacity)),
|
||||
stroke: node.stroke.as_ref().map(|s| {
|
||||
let mut st = scene_stroke(s, &node_id, var_table);
|
||||
st.color = mul_alpha(st.color, cum_opacity);
|
||||
|
|
@ -377,6 +382,17 @@ fn scale_gradient_opacity(g: SceneGradient, k: f32) -> SceneGradient {
|
|||
opacity: (opacity * k).clamp(0.0, 1.0),
|
||||
stops,
|
||||
},
|
||||
SceneGradient::Mesh {
|
||||
rows,
|
||||
cols,
|
||||
colors,
|
||||
opacity,
|
||||
} => SceneGradient::Mesh {
|
||||
rows,
|
||||
cols,
|
||||
colors,
|
||||
opacity: (opacity * k).clamp(0.0, 1.0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -512,6 +528,17 @@ fn payload_gradient_to_scene(g: &GradientPayload) -> SceneGradient {
|
|||
opacity: *opacity,
|
||||
stops: stops.iter().map(stop_to_scene).collect(),
|
||||
},
|
||||
GradientPayload::Mesh {
|
||||
rows,
|
||||
cols,
|
||||
colors,
|
||||
opacity,
|
||||
} => SceneGradient::Mesh {
|
||||
rows: *rows,
|
||||
cols: *cols,
|
||||
colors: colors.iter().map(|c| array_to_color(*c)).collect(),
|
||||
opacity: *opacity,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -522,12 +549,35 @@ fn stop_to_scene(s: &GradientStopPayload) -> SceneGradientStop {
|
|||
}
|
||||
}
|
||||
|
||||
/// Convert a [`ShaderPayload`] into the paint-only [`SceneShader`].
|
||||
/// The SkSL source + pre-resolved uniforms ride through unchanged;
|
||||
/// node opacity (`k`) folds into the shader's own opacity multiplier.
|
||||
/// The `fallback` `[r,g,b,a]` becomes the visible solid colour for
|
||||
/// backends that can't run the program.
|
||||
fn payload_shader_to_scene(s: &ShaderPayload, k: f32) -> SceneShader {
|
||||
SceneShader {
|
||||
sksl: s.sksl.clone(),
|
||||
uniforms: s
|
||||
.uniforms
|
||||
.iter()
|
||||
.map(|u| SceneShaderUniform {
|
||||
name: u.name.clone(),
|
||||
values: u.values.clone(),
|
||||
})
|
||||
.collect(),
|
||||
opacity: (s.opacity * k).clamp(0.0, 1.0),
|
||||
fallback: array_to_color(s.fallback),
|
||||
}
|
||||
}
|
||||
|
||||
/// `NodePayload.fill_type` string → scene `SceneFillType`. Mirrors
|
||||
/// `payload::str_to_fill_type` followed by `fill_type_to_scene`.
|
||||
fn str_to_scene_fill_type(s: &str) -> SceneFillType {
|
||||
match s {
|
||||
"linear" => SceneFillType::LinearGradient,
|
||||
"radial" => SceneFillType::RadialGradient,
|
||||
"mesh" => SceneFillType::MeshGradient,
|
||||
"shader" => SceneFillType::Shader,
|
||||
"image" => SceneFillType::Image,
|
||||
_ => SceneFillType::Solid,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -415,6 +415,103 @@ fn linear_gradient_payload_threads_into_scene_node() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_gradient_payload_threads_into_scene_node_with_first_vertex_fallback() {
|
||||
// A mesh_gradient first-fill must come out of the scene builder as
|
||||
// `SceneNode.gradient = Some(Mesh { .. })` (so the native
|
||||
// draw_vertices path renders the Gouraud blend) AND populate
|
||||
// `SceneNode.fill` with the first-vertex colour as the documented
|
||||
// solid fallback (backends without per-vertex support paint flat).
|
||||
let src = r##"{
|
||||
"version":"1.0.0","pages":[{"id":"p","name":"P","children":[{
|
||||
"type":"rectangle","id":"r","width":100,"height":100,
|
||||
"fill":[{"type":"mesh_gradient","rows":2,"cols":2,
|
||||
"stops":[{"row":0,"col":0,"color":"#FF0000"},
|
||||
{"row":0,"col":1,"color":"#00FF00"},
|
||||
{"row":1,"col":0,"color":"#0000FF"},
|
||||
{"row":1,"col":1,"color":"#FFFF00"}]}]
|
||||
}]}],"children":[]
|
||||
}"##;
|
||||
let scene = editor_state_to_layout_scene(&state_from(src));
|
||||
let n = &scene.pages[0].children[0];
|
||||
let g = n.gradient.as_ref().expect("scene gradient must populate");
|
||||
match g {
|
||||
jian_scene::layout_scene::SceneGradient::Mesh {
|
||||
rows, cols, colors, ..
|
||||
} => {
|
||||
assert_eq!(*rows, 2);
|
||||
assert_eq!(*cols, 2);
|
||||
assert_eq!(colors.len(), 4);
|
||||
// Row-major: (0,0) red, (0,1) green, (1,0) blue, (1,1) yellow.
|
||||
assert!(colors[0].r > 0.99 && colors[0].g < 0.01 && colors[0].b < 0.01);
|
||||
assert!(colors[1].g > 0.99 && colors[1].r < 0.01);
|
||||
assert!(colors[2].b > 0.99 && colors[2].r < 0.01);
|
||||
}
|
||||
other => panic!("expected mesh, got {other:?}"),
|
||||
}
|
||||
// First-vertex solid fallback baked into node.fill.
|
||||
let fill = n.fill.expect("mesh fill must carry first-vertex fallback");
|
||||
assert!(fill.r > 0.99 && fill.g < 0.01 && fill.b < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_payload_threads_into_scene_node_with_uniforms_and_fallback() {
|
||||
// A `shader` first-fill must come out of the scene builder as
|
||||
// `SceneNode.shader = Some(..)` (so the native RuntimeEffect path
|
||||
// runs the program) AND bake the first colour-uniform into
|
||||
// `SceneNode.fill` as the documented solid fallback.
|
||||
let src = r##"{
|
||||
"version":"1.0.0","pages":[{"id":"p","name":"P","children":[{
|
||||
"type":"rectangle","id":"r","width":240,"height":240,
|
||||
"fill":[{"type":"shader",
|
||||
"sksl":"half4 main(float2 p){ return half4(p.x/240.0, p.y/240.0, 0.5, 1.0); }",
|
||||
"uniforms":{"glow":0.5,"tint":"#FF0000"}}]
|
||||
}]}],"children":[]
|
||||
}"##;
|
||||
let scene = editor_state_to_layout_scene(&state_from(src));
|
||||
let n = &scene.pages[0].children[0];
|
||||
let s = n.shader.as_ref().expect("scene shader must populate");
|
||||
assert!(s.sksl.contains("half4 main(float2 p)"));
|
||||
// `glow` (float) + `tint` (color → premultiplied vec4) bind through.
|
||||
let glow = s.uniforms.iter().find(|u| u.name == "glow").expect("glow");
|
||||
assert_eq!(glow.values, vec![0.5]);
|
||||
let tint = s.uniforms.iter().find(|u| u.name == "tint").expect("tint");
|
||||
assert_eq!(tint.values.len(), 4);
|
||||
// Premultiplied opaque red → (1,0,0,1).
|
||||
assert!(tint.values[0] > 0.99 && tint.values[1] < 0.01 && tint.values[3] > 0.99);
|
||||
// fill_type is "shader".
|
||||
assert_eq!(n.fill_type, jian_scene::layout_scene::SceneFillType::Shader);
|
||||
// First colour-uniform solid fallback baked into node.fill (red).
|
||||
let fill = n
|
||||
.fill
|
||||
.expect("shader fill must carry colour-uniform fallback");
|
||||
assert!(fill.r > 0.99 && fill.g < 0.01 && fill.b < 0.01);
|
||||
// Fallback colour on the scene shader itself is red too.
|
||||
assert!(s.fallback.r > 0.99 && s.fallback.g < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_payload_without_color_uniform_falls_back_mid_gray() {
|
||||
// A shader with no colour uniform must still bake a visible fallback
|
||||
// (mid-gray) into both node.fill and the scene shader, so a host
|
||||
// that can't compile the program shows something.
|
||||
let src = r##"{
|
||||
"version":"1.0.0","pages":[{"id":"p","name":"P","children":[{
|
||||
"type":"rectangle","id":"r","width":100,"height":100,
|
||||
"fill":[{"type":"shader",
|
||||
"sksl":"half4 main(float2 p){ return half4(0.0,0.0,0.0,1.0); }"}]
|
||||
}]}],"children":[]
|
||||
}"##;
|
||||
let scene = editor_state_to_layout_scene(&state_from(src));
|
||||
let n = &scene.pages[0].children[0];
|
||||
let s = n.shader.as_ref().expect("scene shader must populate");
|
||||
assert!(s.uniforms.is_empty());
|
||||
// Mid-gray fallback (0.5, 0.5, 0.5).
|
||||
assert!((s.fallback.r - 0.5).abs() < 0.02 && (s.fallback.g - 0.5).abs() < 0.02);
|
||||
let fill = n.fill.expect("shader fill must carry mid-gray fallback");
|
||||
assert!((fill.r - 0.5).abs() < 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_fill_mode_threads_into_scene_node() {
|
||||
let src = r##"{
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ mod effects;
|
|||
mod layout_repair;
|
||||
mod layout_scene;
|
||||
mod legacy_payload_repair;
|
||||
mod library;
|
||||
// Only the real-shaper (`skia-measure`) build benefits from caching; the
|
||||
// estimate backend is already cheap, so the module is gated to avoid dead code
|
||||
// under the CanvasKit (no-skia-measure) web build.
|
||||
|
|
@ -61,6 +62,7 @@ pub use effects::{
|
|||
effects_from_payload, effects_from_payload_ref, effects_to_payload, shadows_from_canonical,
|
||||
ShadowPayload,
|
||||
};
|
||||
pub use library::{merge_library_into_state, merge_library_src_into_state, LibraryMergeReport};
|
||||
pub use payload::{load_canonical, DocPayload, NodePayload, PagePayload, StrokePayload};
|
||||
pub use variables::{var_table_from_payload, var_table_to_payload, VarTablePayload};
|
||||
|
||||
|
|
|
|||
236
crates/op-pen-loader/src/library.rs
Normal file
236
crates/op-pen-loader/src/library.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
//! Component-library loading + merge.
|
||||
//!
|
||||
//! A *component library* is an ordinary `.op` / `.lib.op` document whose
|
||||
//! top-level (or per-page) frames carry `reusable: true`. Loading one into
|
||||
//! a working document makes those masters addressable by `ref` nodes during
|
||||
//! AI generation — the "design-kit composition" path.
|
||||
//!
|
||||
//! [`merge_library_into_state`] reads such a file via the canonical loader,
|
||||
//! harvests its reusable masters with
|
||||
//! [`op_editor_core::ComponentLibrary::from_document`], and merges them into
|
||||
//! a live [`EditorState`]:
|
||||
//!
|
||||
//! 1. the master frames are appended to `state.doc.children` (deduped by id),
|
||||
//! 2. the library's `variables` + `themes` are merged into the doc (so each
|
||||
//! master's `$--token` fills resolve), and
|
||||
//! 3. `state.components` is rebuilt so the runtime registry + the generator's
|
||||
//! available-components manifest see the new masters immediately.
|
||||
//!
|
||||
//! This is intentionally additive and gated: nothing calls it on the default
|
||||
//! path. The smoke runner wires it behind `OPENPENCIL_SMOKE_LIBRARY`, and the
|
||||
//! desktop host can call it when a user imports a kit.
|
||||
|
||||
use op_editor_core::pen_node_ext::PenNodeExt;
|
||||
use op_editor_core::{ComponentLibrary, EditorState};
|
||||
|
||||
use crate::payload::load_canonical;
|
||||
|
||||
/// Outcome of a successful library merge — counts for logging / tests.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct LibraryMergeReport {
|
||||
/// Reusable master frames appended to `doc.children` (post-dedup).
|
||||
pub masters_added: usize,
|
||||
/// Total reusable masters now registered in `state.components`.
|
||||
pub component_count: usize,
|
||||
/// Variable definitions merged in from the library (newly added only).
|
||||
pub variables_added: usize,
|
||||
/// Theme axes merged in from the library (newly added only).
|
||||
pub themes_added: usize,
|
||||
}
|
||||
|
||||
/// Load the `.lib.op` at `path`, harvest its reusable masters, and merge them
|
||||
/// (plus its variables + themes) into `state`. Existing document content is
|
||||
/// preserved; masters/variables/themes whose ids already exist are skipped so
|
||||
/// re-importing the same library is idempotent.
|
||||
///
|
||||
/// Returns the merge report on success, or a human-readable error string when
|
||||
/// the file can't be read or parsed. On error `state` is left unchanged.
|
||||
pub fn merge_library_into_state(
|
||||
state: &mut EditorState,
|
||||
path: &str,
|
||||
) -> Result<LibraryMergeReport, String> {
|
||||
let src = std::fs::read_to_string(path).map_err(|e| format!("read library {path}: {e}"))?;
|
||||
merge_library_src_into_state(state, &src).map_err(|e| format!("load library {path}: {e}"))
|
||||
}
|
||||
|
||||
/// Source-string variant of [`merge_library_into_state`] — parses canonical
|
||||
/// `.op` JSON already in memory. Shared core so tests can drive it without a
|
||||
/// temp file.
|
||||
pub fn merge_library_src_into_state(
|
||||
state: &mut EditorState,
|
||||
src: &str,
|
||||
) -> Result<LibraryMergeReport, String> {
|
||||
let loaded = load_canonical(src).map_err(|e| e.to_string())?;
|
||||
let lib_doc = loaded.value;
|
||||
|
||||
// Harvest reusable masters from the library document (top-level + pages).
|
||||
let library = ComponentLibrary::from_document(&lib_doc);
|
||||
|
||||
let mut report = LibraryMergeReport::default();
|
||||
|
||||
// 1. Append master frames to the working doc, deduped by id.
|
||||
let existing_ids: std::collections::HashSet<String> = state
|
||||
.doc
|
||||
.children
|
||||
.iter()
|
||||
.map(|n| n.id_str().to_string())
|
||||
.collect();
|
||||
let mut added_ids = existing_ids.clone();
|
||||
for component in &library.components {
|
||||
let id = component.root.id_str().to_string();
|
||||
if added_ids.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
added_ids.insert(id);
|
||||
state.doc.children.push(component.root.clone());
|
||||
report.masters_added += 1;
|
||||
}
|
||||
|
||||
// 2. Merge the library's variables (only new names) so master `$--token`
|
||||
// fills resolve against concrete values.
|
||||
if let Some(lib_vars) = lib_doc.variables.as_ref() {
|
||||
let dst = state.doc.variables.get_or_insert_with(Default::default);
|
||||
for (name, def) in lib_vars {
|
||||
if !dst.contains_key(name) {
|
||||
dst.insert(name.clone(), def.clone());
|
||||
report.variables_added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Merge the library's theme axes (only new axis names).
|
||||
if let Some(lib_themes) = lib_doc.themes.as_ref() {
|
||||
let dst = state.doc.themes.get_or_insert_with(Default::default);
|
||||
for (axis, values) in lib_themes {
|
||||
if !dst.contains_key(axis) {
|
||||
dst.insert(axis.clone(), values.clone());
|
||||
report.themes_added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Rebuild the runtime component registry off the merged document so the
|
||||
// generator's available-components manifest sees the new masters now.
|
||||
state.components = ComponentLibrary::from_document(&state.doc);
|
||||
report.component_count = state.components.len();
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One reusable master frame as canonical `.op` JSON (a button-like
|
||||
/// frame whose fill references a `$--primary` token so the merge's
|
||||
/// variable handling matters). Built with `serde_json` so color
|
||||
/// literals like `#18181B` don't trip the raw-string `r#` prefix rules.
|
||||
fn reusable_frame_json(id: &str, name: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"type": "frame",
|
||||
"name": name,
|
||||
"reusable": true,
|
||||
"width": 120,
|
||||
"height": 40,
|
||||
"layout": "horizontal",
|
||||
"cornerRadius": 8,
|
||||
"fill": [{ "type": "solid", "color": "$--primary" }],
|
||||
"children": [
|
||||
{ "id": format!("{id}-label"), "type": "text", "name": "Label",
|
||||
"content": name, "fontSize": 14 }
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// A library document (JSON) with `n` reusable masters + variables + a
|
||||
/// theme axis. Serialized to a canonical `.op` JSON string so it goes
|
||||
/// through the exact same `load_canonical` path a real `.lib.op` file
|
||||
/// would.
|
||||
fn library_src(n: usize) -> String {
|
||||
let children: Vec<serde_json::Value> = (0..n)
|
||||
.map(|i| reusable_frame_json(&format!("lib-comp-{i}"), &format!("Component {i}")))
|
||||
.collect();
|
||||
let doc = serde_json::json!({
|
||||
"version": "1.0",
|
||||
"name": "Test Library",
|
||||
"themes": { "mode": ["light", "dark"] },
|
||||
"variables": {
|
||||
"--primary": { "type": "color", "value": "#18181B" },
|
||||
"--surface": { "type": "color", "value": "#FAFAFA" },
|
||||
},
|
||||
"children": children,
|
||||
});
|
||||
serde_json::to_string(&doc).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_masters_variables_and_themes_into_empty_state() {
|
||||
let mut state = EditorState::new();
|
||||
let src = library_src(120);
|
||||
let report = merge_library_src_into_state(&mut state, &src).expect("merge");
|
||||
|
||||
assert_eq!(report.masters_added, 120);
|
||||
assert_eq!(report.component_count, 120);
|
||||
assert!(
|
||||
report.component_count > 100,
|
||||
"component count must exceed 100"
|
||||
);
|
||||
assert_eq!(report.variables_added, 2);
|
||||
assert_eq!(report.themes_added, 1);
|
||||
// The masters landed in the working doc.
|
||||
assert_eq!(state.doc.children.len(), 120);
|
||||
// Variables + themes are present so master `$--token` fills resolve.
|
||||
assert!(state
|
||||
.doc
|
||||
.variables
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains_key("--primary"));
|
||||
assert!(state.doc.themes.as_ref().unwrap().contains_key("mode"));
|
||||
// The runtime registry sees them too.
|
||||
assert_eq!(state.components.len(), 120);
|
||||
assert!(state.components.find_by_name("Component 7").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_keeps_existing_and_is_idempotent() {
|
||||
let mut state = EditorState::new();
|
||||
let src = library_src(5);
|
||||
let first = merge_library_src_into_state(&mut state, &src).expect("first");
|
||||
assert_eq!(first.masters_added, 5);
|
||||
// Re-importing the same library adds nothing new.
|
||||
let second = merge_library_src_into_state(&mut state, &src).expect("second");
|
||||
assert_eq!(second.masters_added, 0);
|
||||
assert_eq!(second.variables_added, 0);
|
||||
assert_eq!(second.themes_added, 0);
|
||||
// Component count is stable.
|
||||
assert_eq!(second.component_count, 5);
|
||||
assert_eq!(state.doc.children.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_existing_document_content() {
|
||||
// A working document with one ordinary (non-reusable) user frame.
|
||||
let user_doc_src = r#"{"version":"1.0","children":[
|
||||
{"id":"user-frame","type":"frame","name":"User Frame","width":200,"height":100}
|
||||
]}"#;
|
||||
let loaded = load_canonical(user_doc_src).expect("user doc");
|
||||
let mut state = EditorState::from_document(loaded.value);
|
||||
let src = library_src(3);
|
||||
let report = merge_library_src_into_state(&mut state, &src).expect("merge");
|
||||
assert_eq!(report.masters_added, 3);
|
||||
// User content survived + masters appended after it.
|
||||
assert_eq!(state.doc.children.len(), 4);
|
||||
assert_eq!(state.doc.children[0].id_str(), "user-frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_source_leaves_state_unchanged() {
|
||||
let mut state = EditorState::new();
|
||||
let err = merge_library_src_into_state(&mut state, "not json").unwrap_err();
|
||||
assert!(!err.is_empty());
|
||||
assert!(state.doc.children.is_empty());
|
||||
assert!(state.components.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -103,6 +103,12 @@ pub struct NodePayload {
|
|||
/// a fallback for paint paths that don't grok gradients.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gradient: Option<GradientPayload>,
|
||||
/// Resolved native SkSL shader body for the first fill when it is a
|
||||
/// `Shader`. `None` for every other fill type. `fill` still carries
|
||||
/// the shader's fallback colour for paint paths that can't run the
|
||||
/// program.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shader: Option<ShaderPayload>,
|
||||
#[serde(default)]
|
||||
pub points: Vec<[f32; 2]>,
|
||||
/// Path bezier anchors (absolute doc coords, handles resolved).
|
||||
|
|
@ -333,6 +339,42 @@ pub enum GradientPayload {
|
|||
opacity: f32,
|
||||
stops: Vec<GradientStopPayload>,
|
||||
},
|
||||
/// Uniform-grid mesh gradient (v1). `colors` is a row-major
|
||||
/// `rows`×`cols` lattice of pre-resolved RGBA values (length ==
|
||||
/// `rows * cols`); vertex `(r, c)` lives at `colors[r * cols + c]`.
|
||||
/// Opacity is carried separately and folded by the painter (parity
|
||||
/// with how the Linear / Radial variants thread `opacity`).
|
||||
Mesh {
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
colors: Vec<[f32; 4]>,
|
||||
opacity: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// One resolved SkSL shader uniform — name plus a concrete float vector
|
||||
/// (length 1 = float, 2/3/4 = vec*). A `color` uniform is pre-expanded
|
||||
/// into a 4-float premultiplied-RGBA `vec4` here so the scene builder +
|
||||
/// painter never re-walk the canonical schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShaderUniformPayload {
|
||||
pub name: String,
|
||||
pub values: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Layout-resolved native SkSL shader body for `NodePayload.shader`.
|
||||
/// `sksl` is the RAW (untrusted) source; uniforms are pre-resolved.
|
||||
/// `fallback` is the `[r,g,b,a]` solid colour painted when a host can't
|
||||
/// compile the program (first `color` uniform, else mid-gray) — kept
|
||||
/// alongside `NodePayload.fill` so the degradation path always has a
|
||||
/// visible colour.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShaderPayload {
|
||||
pub sksl: String,
|
||||
#[serde(default)]
|
||||
pub uniforms: Vec<ShaderUniformPayload>,
|
||||
pub opacity: f32,
|
||||
pub fallback: [f32; 4],
|
||||
}
|
||||
|
||||
/// One path bezier anchor in absolute doc coords. `handle_in` /
|
||||
|
|
@ -407,7 +449,12 @@ pub fn load_canonical(
|
|||
fn normalize_legacy_doc(src: &str) -> Option<String> {
|
||||
let mut value: serde_json::Value = serde_json::from_str(src).ok()?;
|
||||
let mut changed = false;
|
||||
normalize_node_value(&mut value, &mut changed);
|
||||
// Pencil design-kit `.pen` files reference `$--radius-*` number variables
|
||||
// from `cornerRadius`; the canonical `CornerRadius` enum can only hold a
|
||||
// number / `[f64; 4]`, so collect the document's number-variable table up
|
||||
// front to resolve those refs to concrete radii during the walk.
|
||||
let radius_vars = collect_number_variables(&value);
|
||||
normalize_node_value(&mut value, &radius_vars, &mut changed);
|
||||
if changed {
|
||||
serde_json::to_string(&value).ok()
|
||||
} else {
|
||||
|
|
@ -415,36 +462,131 @@ fn normalize_legacy_doc(src: &str) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Harvest `variables.<name> = { type: "number", value: N | [{value, theme}] }`
|
||||
/// into a `$<name>` → first-concrete-number map. Used to resolve
|
||||
/// `cornerRadius: "$--radius-pill"` legacy refs (Pencil design kits) into the
|
||||
/// number the canonical `CornerRadius` enum can actually hold. Theme-keyed
|
||||
/// arrays collapse to their first entry's value — corner radius is rarely
|
||||
/// theme-varied, and a constant fallback beats refusing the whole file.
|
||||
fn collect_number_variables(root: &serde_json::Value) -> std::collections::HashMap<String, f64> {
|
||||
use serde_json::Value;
|
||||
let mut out = std::collections::HashMap::new();
|
||||
let Some(vars) = root.get("variables").and_then(Value::as_object) else {
|
||||
return out;
|
||||
};
|
||||
for (name, def) in vars {
|
||||
if def.get("type").and_then(Value::as_str) != Some("number") {
|
||||
continue;
|
||||
}
|
||||
let resolved = match def.get("value") {
|
||||
Some(Value::Number(n)) => n.as_f64(),
|
||||
Some(Value::Array(entries)) => entries
|
||||
.first()
|
||||
.and_then(|e| e.get("value"))
|
||||
.and_then(Value::as_f64),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(v) = resolved {
|
||||
out.insert(format!("${name}"), v);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Iterative (explicit stack, not recursion — deep trees must not blow the
|
||||
/// stack) walk. Objects carrying a `type` key are PenNodes: jian requires
|
||||
/// their `fill` to be `Vec<PenFill>` and (for images) a `src`. Type-less
|
||||
/// objects (e.g. `StyledTextSegment`, whose `fill` is a `String`) are left
|
||||
/// untouched.
|
||||
fn normalize_node_value(root: &mut serde_json::Value, changed: &mut bool) {
|
||||
/// stack) walk that repairs known legacy `.pen` / `.op` shapes the strict
|
||||
/// canonical schema rejects but the TS runtime tolerated:
|
||||
///
|
||||
/// - PenNode `fill` written as a bare color/`$ref` string → wrap into the
|
||||
/// `Vec<PenFill>` jian expects.
|
||||
/// - Image node missing the required `src`.
|
||||
/// - Pencil legacy `type: "icon"` → canonical `icon_font`.
|
||||
/// - A `stroke` written as a bare color string → wrap into a `PenStroke`.
|
||||
/// - A stroke object's `fill` string (no `type` key on the stroke object) →
|
||||
/// wrap into `Vec<PenFill>` (same shape as node fill).
|
||||
/// - `cornerRadius` written as a `$ref` string (or array of them) → resolved
|
||||
/// to the concrete number(s) the `CornerRadius` enum can hold.
|
||||
///
|
||||
/// Type-less objects whose `fill` is legitimately a `String`
|
||||
/// (`StyledTextSegment`) are left untouched — the wrap is gated on the object
|
||||
/// being a PenNode (`type` key) or a stroke (`thickness` key).
|
||||
fn normalize_node_value(
|
||||
root: &mut serde_json::Value,
|
||||
number_vars: &std::collections::HashMap<String, f64>,
|
||||
changed: &mut bool,
|
||||
) {
|
||||
use serde_json::Value;
|
||||
let mut stack = vec![root];
|
||||
while let Some(node) = stack.pop() {
|
||||
match node {
|
||||
Value::Object(map) => {
|
||||
if map.contains_key("type") {
|
||||
if map.get("type").and_then(Value::as_str) == Some("image")
|
||||
&& !map.contains_key("src")
|
||||
{
|
||||
let is_pen_node = map.contains_key("type");
|
||||
if is_pen_node {
|
||||
// Copy the kind decisions to owned bools so the mutating
|
||||
// inserts below don't conflict with the borrow on `map`.
|
||||
let kind = map.get("type").and_then(Value::as_str);
|
||||
let is_image = kind == Some("image");
|
||||
let is_icon = kind == Some("icon");
|
||||
let is_prompt = kind == Some("prompt");
|
||||
if is_image && !map.contains_key("src") {
|
||||
map.insert("src".to_string(), Value::String(String::new()));
|
||||
*changed = true;
|
||||
}
|
||||
// A PenNode `fill` written as a bare color string → wrap
|
||||
// into the canonical solid-fill array jian expects.
|
||||
if let Some(color) = map.get("fill").and_then(|f| match f {
|
||||
// Pencil's `prompt` node is an AI-annotation card with no
|
||||
// canonical equivalent → degrade to a `text` node. Its
|
||||
// `content` field already matches `TextNode.content`; just
|
||||
// drop the editor-only `model` field.
|
||||
if is_prompt {
|
||||
map.insert("type".to_string(), Value::String("text".to_string()));
|
||||
map.remove("model");
|
||||
*changed = true;
|
||||
}
|
||||
// Pencil's legacy `icon` node is jian's `icon_font`: the
|
||||
// glyph lives under `icon` (→ `iconFontName`) and the font
|
||||
// family under `library` (→ `iconFontFamily`).
|
||||
if is_icon {
|
||||
map.insert("type".to_string(), Value::String("icon_font".to_string()));
|
||||
if let Some(glyph) = map.remove("icon") {
|
||||
map.insert("iconFontName".to_string(), glyph);
|
||||
}
|
||||
if let Some(family) = map.remove("library") {
|
||||
map.insert("iconFontFamily".to_string(), family);
|
||||
}
|
||||
*changed = true;
|
||||
}
|
||||
// A PenNode `fill` written as a bare string / single object /
|
||||
// legacy `type:"color"` array → canonical `Vec<PenFill>`.
|
||||
normalize_fill(map, changed);
|
||||
// `cornerRadius` as a `$ref` string / array of them →
|
||||
// resolve to the number(s) the `CornerRadius` enum holds.
|
||||
normalize_corner_radius(map, number_vars, changed);
|
||||
// A `stroke` written as a bare color string → wrap into a
|
||||
// minimal `PenStroke { thickness: 1, fill: [...] }`.
|
||||
if let Some(color) = map.get("stroke").and_then(|s| match s {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
map.insert(
|
||||
"fill".to_string(),
|
||||
serde_json::json!([{ "type": "solid", "color": color }]),
|
||||
"stroke".to_string(),
|
||||
serde_json::json!({
|
||||
"thickness": 1,
|
||||
"fill": [{ "type": "solid", "color": color }],
|
||||
}),
|
||||
);
|
||||
*changed = true;
|
||||
}
|
||||
} else if map.contains_key("thickness") {
|
||||
// Stroke object (has `thickness`, no `type`): its `fill`
|
||||
// is the same normalization target as a node's.
|
||||
normalize_fill(map, changed);
|
||||
}
|
||||
// Spacing fields (`padding` / `gap` / `margin`) may be numeric
|
||||
// arrays with an embedded `$ref` string (`[8, "$spacing/3"]`).
|
||||
// The `Padding` enum's array arms are all-number, so resolve any
|
||||
// ref element to its number. A bare-string padding (`"$x"`) is
|
||||
// left alone — the `Expression(String)` arm accepts it.
|
||||
for key in ["padding", "gap", "margin"] {
|
||||
resolve_spacing_ref_array(map, key, number_vars, changed);
|
||||
}
|
||||
stack.extend(map.values_mut());
|
||||
}
|
||||
|
|
@ -454,6 +596,222 @@ fn normalize_node_value(root: &mut serde_json::Value, changed: &mut bool) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Normalize a `fill` into the canonical `Vec<PenFill>` jian expects. Handles
|
||||
/// the legacy Pencil shapes the strict schema rejects:
|
||||
/// - bare color/`$ref` string (`"#1A1D2E"` / `"$--sidebar-border"`)
|
||||
/// - a single fill *object* (`{ "type": "color", "color": "...", "enabled": false }`)
|
||||
/// rather than a one-element array
|
||||
/// - the `type: "color"` discriminant (jian's solid arm is `"solid"`)
|
||||
/// - an `enabled: false` flag → the fill is dropped (disabled in the source)
|
||||
///
|
||||
/// No-op when `fill` is absent or already a clean canonical array.
|
||||
fn normalize_fill(map: &mut serde_json::Map<String, serde_json::Value>, changed: &mut bool) {
|
||||
use serde_json::Value;
|
||||
let Some(fill) = map.get("fill") else {
|
||||
return;
|
||||
};
|
||||
match fill {
|
||||
Value::String(s) => {
|
||||
let color = s.clone();
|
||||
map.insert(
|
||||
"fill".to_string(),
|
||||
serde_json::json!([{ "type": "solid", "color": color }]),
|
||||
);
|
||||
*changed = true;
|
||||
}
|
||||
Value::Object(_) => {
|
||||
// A single fill written as an object → array of (maybe) one,
|
||||
// after legacy-discriminant + enabled normalization.
|
||||
let mut one = fill.clone();
|
||||
let mut dummy = false;
|
||||
let arr: Vec<Value> = match normalize_fill_entry(&mut one, &mut dummy) {
|
||||
Some(v) => vec![v],
|
||||
None => vec![],
|
||||
};
|
||||
map.insert("fill".to_string(), Value::Array(arr));
|
||||
*changed = true;
|
||||
}
|
||||
Value::Array(items) => {
|
||||
// Already an array — but its elements may still carry the legacy
|
||||
// `type: "color"` discriminant or an `enabled: false` flag.
|
||||
let mut local_changed = false;
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items.clone() {
|
||||
let mut entry = item;
|
||||
if let Some(v) = normalize_fill_entry(&mut entry, &mut local_changed) {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
if local_changed {
|
||||
map.insert("fill".to_string(), Value::Array(out));
|
||||
*changed = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize one PenFill value. Returns `None` when the fill is explicitly
|
||||
/// `enabled: false` (a disabled fill is dropped from the array). Rewrites the
|
||||
/// legacy `type: "color"` discriminant to `"solid"` and strips the non-schema
|
||||
/// `enabled` key. Sets `changed` when it touches anything.
|
||||
fn normalize_fill_entry(
|
||||
entry: &mut serde_json::Value,
|
||||
changed: &mut bool,
|
||||
) -> Option<serde_json::Value> {
|
||||
use serde_json::Value;
|
||||
let Value::Object(obj) = entry else {
|
||||
return Some(entry.clone());
|
||||
};
|
||||
if obj.get("enabled") == Some(&Value::Bool(false)) {
|
||||
*changed = true;
|
||||
return None;
|
||||
}
|
||||
if obj.remove("enabled").is_some() {
|
||||
*changed = true;
|
||||
}
|
||||
match obj.get("type").and_then(Value::as_str) {
|
||||
Some("color") => {
|
||||
obj.insert("type".to_string(), Value::String("solid".to_string()));
|
||||
*changed = true;
|
||||
}
|
||||
Some("gradient") => {
|
||||
normalize_gradient_fill(obj, changed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(Value::Object(obj.clone()))
|
||||
}
|
||||
|
||||
/// Rewrite Pencil's generic `type: "gradient"` fill into the canonical
|
||||
/// `linear_gradient` / `radial_gradient` PenFill. Source shape:
|
||||
/// `{ type: "gradient", gradientType: "linear"|"radial", rotation, colors: [{ color, position }], size }`.
|
||||
/// Canonical shape: `{ type: "linear_gradient", angle, stops: [{ offset, color }] }`.
|
||||
fn normalize_gradient_fill(
|
||||
obj: &mut serde_json::Map<String, serde_json::Value>,
|
||||
changed: &mut bool,
|
||||
) {
|
||||
use serde_json::Value;
|
||||
let radial = obj.get("gradientType").and_then(Value::as_str) == Some("radial");
|
||||
obj.insert(
|
||||
"type".to_string(),
|
||||
Value::String(
|
||||
if radial {
|
||||
"radial_gradient"
|
||||
} else {
|
||||
"linear_gradient"
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
obj.remove("gradientType");
|
||||
// `rotation` (degrees) → `angle`; only for the linear arm (radial ignores it).
|
||||
if let Some(rot) = obj.remove("rotation") {
|
||||
if !radial {
|
||||
obj.insert("angle".to_string(), rot);
|
||||
}
|
||||
}
|
||||
obj.remove("size");
|
||||
// `colors: [{ color, position }]` → `stops: [{ offset, color }]`.
|
||||
if let Some(Value::Array(colors)) = obj.remove("colors") {
|
||||
let stops: Vec<Value> = colors
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let o = c.as_object()?;
|
||||
let color = o.get("color").and_then(Value::as_str)?.to_string();
|
||||
let offset = o.get("position").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
Some(serde_json::json!({ "offset": offset, "color": color }))
|
||||
})
|
||||
.collect();
|
||||
obj.insert("stops".to_string(), Value::Array(stops));
|
||||
} else if !obj.contains_key("stops") {
|
||||
// A gradient with no stops is illegal; seed an empty array so the
|
||||
// canonical loader gets a valid (if degenerate) gradient body.
|
||||
obj.insert("stops".to_string(), Value::Array(vec![]));
|
||||
}
|
||||
*changed = true;
|
||||
}
|
||||
|
||||
/// Resolve `$ref` strings embedded in a numeric spacing array
|
||||
/// (`padding`/`gap`/`margin`: `[8, "$spacing/3"]`) to their concrete numbers.
|
||||
/// No-op unless the value is an array containing at least one `$ref` string —
|
||||
/// a bare-string spacing is left for the schema's `Expression` arm.
|
||||
fn resolve_spacing_ref_array(
|
||||
map: &mut serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
number_vars: &std::collections::HashMap<String, f64>,
|
||||
changed: &mut bool,
|
||||
) {
|
||||
use serde_json::Value;
|
||||
let Some(Value::Array(items)) = map.get(key) else {
|
||||
return;
|
||||
};
|
||||
if !items.iter().any(Value::is_string) {
|
||||
return;
|
||||
}
|
||||
let resolved: Vec<Value> = items
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::String(s) => {
|
||||
serde_json::json!(number_vars.get(s).copied().unwrap_or(0.0))
|
||||
}
|
||||
other => other.clone(),
|
||||
})
|
||||
.collect();
|
||||
map.insert(key.to_string(), Value::Array(resolved));
|
||||
*changed = true;
|
||||
}
|
||||
|
||||
/// Resolve a `cornerRadius` that is a `$ref` string, or an array of
|
||||
/// `$ref`/number entries, into the number / `[f64; 4]` the canonical
|
||||
/// `CornerRadius` enum accepts. A ref with no matching number variable falls
|
||||
/// back to `0.0` so the file still loads (an unresolved radius is cosmetic).
|
||||
fn normalize_corner_radius(
|
||||
map: &mut serde_json::Map<String, serde_json::Value>,
|
||||
number_vars: &std::collections::HashMap<String, f64>,
|
||||
changed: &mut bool,
|
||||
) {
|
||||
use serde_json::Value;
|
||||
let resolve_scalar = |v: &Value| -> Option<f64> {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64(),
|
||||
Value::String(s) => Some(number_vars.get(s).copied().unwrap_or(0.0)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
match map.get("cornerRadius") {
|
||||
Some(Value::String(s)) => {
|
||||
let n = number_vars.get(s).copied().unwrap_or(0.0);
|
||||
map.insert("cornerRadius".to_string(), serde_json::json!(n));
|
||||
*changed = true;
|
||||
}
|
||||
Some(Value::Array(entries)) => {
|
||||
// Only rewrite if at least one entry is a `$ref` string (a plain
|
||||
// numeric `[f64; 4]` is already valid; leave it alone).
|
||||
if entries.iter().any(|e| e.is_string()) {
|
||||
let nums: Vec<f64> = entries.iter().filter_map(&resolve_scalar).collect();
|
||||
// The enum's array arm is exactly `[f64; 4]`. Pad/truncate so a
|
||||
// 1- or 2-value Pencil shorthand still lands in a legal shape.
|
||||
let four = match nums.len() {
|
||||
0 => [0.0; 4],
|
||||
1 => [nums[0]; 4],
|
||||
n if n >= 4 => [nums[0], nums[1], nums[2], nums[3]],
|
||||
_ => {
|
||||
let mut a = [0.0; 4];
|
||||
for (i, v) in nums.iter().enumerate() {
|
||||
a[i] = *v;
|
||||
}
|
||||
a
|
||||
}
|
||||
};
|
||||
map.insert("cornerRadius".to_string(), serde_json::json!(four));
|
||||
*changed = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_str_or_deep(
|
||||
src: &str,
|
||||
) -> Result<
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@
|
|||
use jian_ops_schema::node::base::PenNodeBase;
|
||||
use jian_ops_schema::node::container::CornerRadius;
|
||||
use jian_ops_schema::node::{ImageFitMode, ImageNode};
|
||||
use jian_ops_schema::style::{ImageFillMode, PenFill, PenStroke, StrokeThickness};
|
||||
use jian_ops_schema::style::{
|
||||
ImageFillMode, PenFill, PenStroke, ShaderUniformValue, StrokeThickness,
|
||||
};
|
||||
|
||||
use crate::payload::{
|
||||
GradientPayload, GradientStopPayload, ImageAdjustmentPayload, NodePayload, StrokePayload,
|
||||
GradientPayload, GradientStopPayload, ImageAdjustmentPayload, NodePayload, ShaderPayload,
|
||||
ShaderUniformPayload, StrokePayload,
|
||||
};
|
||||
|
||||
pub(crate) fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload {
|
||||
|
|
@ -38,6 +41,7 @@ pub(crate) fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload {
|
|||
collapsed: false,
|
||||
fill_type: "solid".into(),
|
||||
gradient: None,
|
||||
shader: None,
|
||||
points: Vec::new(),
|
||||
path_anchors: Vec::new(),
|
||||
path_closed: false,
|
||||
|
|
@ -88,6 +92,7 @@ pub(crate) fn assign_first_fill(p: &mut NodePayload, fills: Option<&[PenFill]>)
|
|||
p.fill = first_solid_color(fills);
|
||||
p.fill_type = first_fill_type(fills);
|
||||
p.gradient = first_gradient(fills);
|
||||
p.shader = first_shader(fills);
|
||||
if let Some((url, fit, adjustments)) = first_image_fill(fills) {
|
||||
p.image_src = Some(url);
|
||||
p.image_fit = Some(fit);
|
||||
|
|
@ -211,10 +216,97 @@ fn first_gradient(fills: Option<&[PenFill]>) -> Option<GradientPayload> {
|
|||
stops,
|
||||
})
|
||||
}
|
||||
PenFill::MeshGradient(body) => {
|
||||
let colors = mesh_colors(body)?;
|
||||
Some(GradientPayload::Mesh {
|
||||
rows: body.rows.max(2),
|
||||
cols: body.cols.max(2),
|
||||
colors,
|
||||
opacity: body.opacity.unwrap_or(1.0).clamp(0.0, 1.0),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the first fill into a [`ShaderPayload`] when it is a
|
||||
/// `Shader`. Uniforms are pre-resolved (a `color` hex → premultiplied
|
||||
/// `vec4`); the fallback colour is the first `color` uniform, else
|
||||
/// mid-gray, so a host that can't compile the program still paints a
|
||||
/// visible block. SkSL source stays untrusted — not validated here.
|
||||
fn first_shader(fills: Option<&[PenFill]>) -> Option<ShaderPayload> {
|
||||
let PenFill::Shader(body) = fills?.first()? else {
|
||||
return None;
|
||||
};
|
||||
if body.sksl.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut uniforms: Vec<ShaderUniformPayload> = Vec::new();
|
||||
let mut fallback: Option<[f32; 4]> = None;
|
||||
if let Some(map) = &body.uniforms {
|
||||
for (name, val) in map {
|
||||
match val {
|
||||
ShaderUniformValue::Float(f) => uniforms.push(ShaderUniformPayload {
|
||||
name: name.clone(),
|
||||
values: vec![*f],
|
||||
}),
|
||||
ShaderUniformValue::Vec(v) => {
|
||||
if !v.is_empty() {
|
||||
uniforms.push(ShaderUniformPayload {
|
||||
name: name.clone(),
|
||||
values: v.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
ShaderUniformValue::Color(hex) => {
|
||||
if let Some(rgba) = parse_hex(hex) {
|
||||
// Premultiply for the vec4 binding (matches the
|
||||
// jian-core scene walker's color-uniform rule).
|
||||
let a = rgba[3];
|
||||
uniforms.push(ShaderUniformPayload {
|
||||
name: name.clone(),
|
||||
values: vec![rgba[0] * a, rgba[1] * a, rgba[2] * a, a],
|
||||
});
|
||||
if fallback.is_none() {
|
||||
fallback = Some(rgba);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ShaderPayload {
|
||||
sksl: body.sksl.clone(),
|
||||
uniforms,
|
||||
opacity: body.opacity.unwrap_or(1.0).clamp(0.0, 1.0),
|
||||
// Mid-gray when no colour uniform exists.
|
||||
fallback: fallback.unwrap_or([0.5, 0.5, 0.5, 1.0]),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a mesh body's `stops[]` into a row-major `rows`×`cols`
|
||||
/// colour grid (length == `rows * cols`). Vertices missing from the
|
||||
/// sparse `stops[]` default to transparent black so the grid is always
|
||||
/// fully populated for the triangulator. Returns `None` for a
|
||||
/// degenerate (< 2×2) grid so the caller falls back to solid.
|
||||
fn mesh_colors(body: &jian_ops_schema::style::MeshGradientBody) -> Option<Vec<[f32; 4]>> {
|
||||
let rows = body.rows.max(2);
|
||||
let cols = body.cols.max(2);
|
||||
if body.rows < 2 || body.cols < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut colors = vec![[0.0, 0.0, 0.0, 0.0]; (rows * cols) as usize];
|
||||
for s in &body.stops {
|
||||
if s.row >= rows || s.col >= cols {
|
||||
continue;
|
||||
}
|
||||
if let Some(rgba) = parse_hex(&s.color) {
|
||||
colors[(s.row * cols + s.col) as usize] = rgba;
|
||||
}
|
||||
}
|
||||
Some(colors)
|
||||
}
|
||||
|
||||
fn gradient_stops(
|
||||
stops: &[jian_ops_schema::style::GradientStop],
|
||||
) -> Option<Vec<GradientStopPayload>> {
|
||||
|
|
@ -255,6 +347,29 @@ fn first_solid_color(fills: Option<&[PenFill]>) -> Option<[f32; 4]> {
|
|||
}
|
||||
}
|
||||
}
|
||||
PenFill::MeshGradient(body) => {
|
||||
// First-vertex colour is the documented solid fallback
|
||||
// baked into `node.fill` (backends without per-vertex
|
||||
// support paint this flat).
|
||||
if let Some(stop) = body.stops.first() {
|
||||
if let Some(rgba) = parse_hex(&stop.color) {
|
||||
return Some(apply_alpha(rgba, body.opacity));
|
||||
}
|
||||
}
|
||||
}
|
||||
PenFill::Shader(body) => {
|
||||
// Fallback solid baked into `node.fill`: the first colour
|
||||
// uniform if any, else mid-gray. Backends that can't
|
||||
// compile the program paint this flat.
|
||||
let from_uniform = body.uniforms.as_ref().and_then(|m| {
|
||||
m.values().find_map(|v| match v {
|
||||
ShaderUniformValue::Color(hex) => parse_hex(hex),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let rgba = from_uniform.unwrap_or([0.5, 0.5, 0.5, 1.0]);
|
||||
return Some(apply_alpha(rgba, body.opacity));
|
||||
}
|
||||
PenFill::Image(_) => {
|
||||
return Some([0.85, 0.86, 0.88, 1.0]);
|
||||
}
|
||||
|
|
@ -270,6 +385,8 @@ fn first_fill_type(fills: Option<&[PenFill]>) -> String {
|
|||
match fills.first() {
|
||||
Some(PenFill::LinearGradient(_)) => "linear".into(),
|
||||
Some(PenFill::RadialGradient(_)) => "radial".into(),
|
||||
Some(PenFill::MeshGradient(_)) => "mesh".into(),
|
||||
Some(PenFill::Shader(_)) => "shader".into(),
|
||||
Some(PenFill::Image(_)) => "image".into(),
|
||||
_ => "solid".into(),
|
||||
}
|
||||
|
|
|
|||
2
vendor/jian
vendored
2
vendor/jian
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 8ed9a89fa46b13bb26524e3176b708b2ed6b2970
|
||||
Subproject commit ff39a4163edee286fa7bd8c6afc969bcca2cbaec
|
||||
Loading…
Reference in a new issue