M13.6: PSD smart objects apply perspective quads and placed-layer warps
nonAffineTransform and the warp descriptor (named styles with bend / distortion, custom envelope quilt meshes via ObAr meshPoints) are baked as placed over the placed bounds (SourceLayer::placed), at up to 4x resolution. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7d944ca494
commit
7b0a449c5f
|
|
@ -217,7 +217,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
{
|
||||
match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) {
|
||||
Some((index, name)) => {
|
||||
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None })
|
||||
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false })
|
||||
}
|
||||
None => {
|
||||
errors.push(format!("{path}: no layer {sel}"));
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None }),
|
||||
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None, placed: false }),
|
||||
..Default::default()
|
||||
};
|
||||
let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
|
||||
|
|
@ -191,8 +191,20 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz
|
|||
let pl = &cx.psd.layers[index];
|
||||
let so = pl.smart_object.clone()?;
|
||||
let data = cx.psd.linked_data(&so.uuid)?;
|
||||
let (w, h) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?;
|
||||
let (cw, ch) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?;
|
||||
let file = cx.psd.linked.iter().find(|f| f.uuid == so.uuid).map(|f| f.name.clone()).unwrap_or_default();
|
||||
// Perspective quads and warps are baked over the placed bounds, at up to 4× the document
|
||||
// resolution when the embedded content has that much detail.
|
||||
let bake = so.needs_bake();
|
||||
let (w, h, k, bounds) = if bake {
|
||||
let bb = so.placed_bounds(cw as f64, ch as f64);
|
||||
let area = ((bb[2] - bb[0]) * (bb[3] - bb[1])).max(1.0);
|
||||
let k = ((cw as f64 * ch as f64) / area).sqrt().clamp(1.0, 4.0);
|
||||
let (w, h) = so.placed_size(cw as f64, ch as f64, k);
|
||||
(w, h, k, bb)
|
||||
} else {
|
||||
(cw, ch, 1.0, [0.0; 4])
|
||||
};
|
||||
let f = Footage {
|
||||
path: cx.path.to_string(),
|
||||
kind: FootageKind::Still,
|
||||
|
|
@ -204,13 +216,20 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz
|
|||
alpha: AlphaMode::Straight,
|
||||
loop_count: 1,
|
||||
codec: if effectcraft_psd::is_psd(data) { "PSD".into() } else { "PSD (embedded image)".into() },
|
||||
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()) }),
|
||||
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()), placed: bake }),
|
||||
..Default::default()
|
||||
};
|
||||
let label = if file.is_empty() { name.to_string() } else { file };
|
||||
let id = proj.add_item(&format!("{label} (Smart Object)"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
|
||||
cx.items.push(id);
|
||||
let mut l = build::layer(proj, comp, name, LayerSource::Footage { item: id }, (w, h), None);
|
||||
if bake {
|
||||
// The baked pixels cover the placed bounds: centred there, scaled back to 1×.
|
||||
let c = [bounds[0] + w as f64 / k / 2.0, bounds[1] + h as f64 / k / 2.0];
|
||||
set_vec2(&mut l, "transform/position", c);
|
||||
set_vec2(&mut l, "transform/scale", [100.0 / k, 100.0 / k]);
|
||||
return Some(l);
|
||||
}
|
||||
let (c, sx, sy, rot) = so.placement(w as f64, h as f64);
|
||||
set_vec2(&mut l, "transform/position", c);
|
||||
set_vec2(&mut l, "transform/scale", [sx * 100.0, sy * 100.0]);
|
||||
|
|
|
|||
|
|
@ -405,3 +405,59 @@ fn pdf_pages_text_and_clips_to_masks() {
|
|||
let comp = s.project.comp(ItemId(r["comps"][0].as_u64().unwrap())).unwrap();
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() {
|
||||
let mut inner = WDoc::new(20, 10);
|
||||
inner.layers =
|
||||
vec![WLayer::solid("Red", Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]), WLayer::solid("Green", Rect::new(10, 0, 10, 10), [0.0, 1.0, 0.0, 1.0])];
|
||||
let inner_bytes = write(&inner);
|
||||
let mut d = WDoc::new(64, 48);
|
||||
// A perspective trapezoid, and an affine placement with an Arc warp.
|
||||
let trap = [[20.0, 4.0], [44.0, 10.0], [44.0, 38.0], [20.0, 44.0]];
|
||||
let warp = warp_descriptor("warpArc", 50.0, 0.0, 0.0, [0.0, 0.0, 20.0, 10.0], None);
|
||||
d.layers = vec![
|
||||
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
|
||||
WLayer::solid("Pinned", Rect::new(20, 4, 24, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped("so-1", trap, [20.0, 10.0], None)),
|
||||
];
|
||||
d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes.clone())];
|
||||
let mut d2 = WDoc::new(64, 48);
|
||||
d2.layers = vec![
|
||||
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
|
||||
WLayer::solid("Arc", Rect::new(20, 10, 40, 20), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped(
|
||||
"so-2",
|
||||
[[20.0, 10.0], [60.0, 10.0], [60.0, 30.0], [20.0, 30.0]],
|
||||
[20.0, 10.0],
|
||||
Some(warp),
|
||||
)),
|
||||
];
|
||||
d2.linked = vec![("so-2".into(), "inner.psd".into(), *b"8BPS", inner_bytes)];
|
||||
let parsed = effectcraft_psd::Psd::parse(write(&d2)).unwrap();
|
||||
let so = parsed.layers.iter().find_map(|l| l.smart_object.clone()).unwrap();
|
||||
assert_eq!(so.warp.as_ref().map(|w| w.style.as_str()), Some("warpArc"));
|
||||
let mut s = effectcraft_host::session();
|
||||
let render = |s: &mut effectcraft_engine::Session, doc: &WDoc, file: &str| {
|
||||
let path = tmp(file);
|
||||
std::fs::write(&path, write(doc)).unwrap();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let placed = s.project.comp(cid).unwrap().layers.iter().any(|l| {
|
||||
let effectcraft_project::LayerSource::Footage { item } = l.source else { return false };
|
||||
matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.layer.as_ref().is_some_and(|x| x.placed))
|
||||
});
|
||||
assert!(placed, "{file}: baked as placed");
|
||||
s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default())
|
||||
};
|
||||
let img = render(&mut s, &d, "pinned.psd");
|
||||
let (red, green) = (img.get(24, 24), img.get(40, 24));
|
||||
assert!(red[0] > 0.9 && red[1] < 0.1, "{red:?}");
|
||||
assert!(green[1] > 0.9 && green[0] < 0.1, "{green:?}");
|
||||
// Above the slanted top edge (y = 4 + 6·(x − 20)/24): background.
|
||||
assert!(img.get(42, 6)[0] < 0.05 && img.get(42, 6)[1] < 0.05, "{:?}", img.get(42, 6));
|
||||
let img = render(&mut s, &d2, "arc.psd");
|
||||
// The arc keeps the top centre and pulls the corners down and out.
|
||||
let top = img.get(40, 11);
|
||||
assert!(top[0] > 0.5 || top[1] > 0.5, "top centre covered {top:?}");
|
||||
assert!(img.get(21, 11)[0] < 0.05 && img.get(21, 11)[1] < 0.05, "corner region empty {:?}", img.get(21, 11));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,13 +75,26 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
|
|||
// A smart object's embedded file: an embedded document's merged image, or an image.
|
||||
if let Some(uuid) = footage.layer.as_ref().and_then(|l| l.embedded.as_deref()) {
|
||||
let data = psd.linked_data(uuid).ok_or_else(|| MediaError::Decode(format!("{path}: no embedded file {uuid}")))?;
|
||||
if effectcraft_psd::is_psd(data) {
|
||||
let px = if effectcraft_psd::is_psd(data) {
|
||||
let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
|
||||
let px = inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
|
||||
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
|
||||
inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?
|
||||
} else {
|
||||
let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
|
||||
let rgba = img.to_rgba32f();
|
||||
effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() }
|
||||
};
|
||||
// A perspective quad or a warp: baked as placed.
|
||||
let l = footage.layer.as_ref().expect("embedded implies a layer");
|
||||
if l.placed
|
||||
&& let Some(so) = psd.layers.get(l.index as usize).and_then(|pl| pl.smart_object.as_ref())
|
||||
{
|
||||
let bb = so.placed_bounds(px.width as f64, px.height as f64);
|
||||
let k = footage.width as f64 / (bb[2] - bb[0]).max(1e-9);
|
||||
if let Some((baked, _)) = so.render_placed(&px, k, Some((footage.width, footage.height))) {
|
||||
return Ok(Some(straight_to_image(baked.width, baked.height, &baked.data, op)));
|
||||
}
|
||||
}
|
||||
let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
|
||||
return Ok(Some(crate::convert::dynamic_to_image(&img, op)));
|
||||
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
|
||||
}
|
||||
let px = match &footage.layer {
|
||||
Some(l) => psd.layer_pixels(l.index as usize, !l.layer_size),
|
||||
|
|
|
|||
|
|
@ -848,6 +848,10 @@ pub struct SourceLayer {
|
|||
/// is the footage instead of the layer's pixels.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub embedded: Option<String>,
|
||||
/// A smart object with a perspective quad or a warp: the footage is its embedded file baked
|
||||
/// as placed (warped and pinned to its corners) over the placed bounds in document space.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub placed: bool,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ Bitmap, Grayscale, Indexed, RGB, CMYK (naive conversion) and Lab colour modes.
|
|||
Text engine data (`EngineData`) is read best-effort for the font, size, fill colour and
|
||||
justification of the first style run. Smart objects import as footage of their embedded file
|
||||
(a Photoshop document's merged image, or an image) placed by the smart object's corner quad
|
||||
(position, scale, rotation; perspective and warps are not applied). Externally linked files
|
||||
(position, scale, rotation). A non-affine quad (`nonAffineTransform`: perspective) or a placed
|
||||
layer warp (`warp`: the named styles with bend and horizontal / vertical distortion, and custom
|
||||
envelope warps whose quilt mesh of bicubic Bezier patches is read from `meshPoints` (`ObAr`)
|
||||
and `quiltSliceX` / `quiltSliceY`) is baked as placed over the placed bounds, at up to 4x the
|
||||
document resolution ([`placed`](src/placed.rs)). Custom meshes are evaluated exactly; the
|
||||
named styles' curves are not published, so they are clean-room geometric definitions matching
|
||||
each style by behaviour. Externally linked files
|
||||
fall back to the layer's rendered pixels. No Adobe code, sample files or assets were used; test
|
||||
fixtures are generated in-test by the writer in this crate ([`write`](src/write.rs)).
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ pub enum DValue {
|
|||
Class(String, String),
|
||||
Raw(Vec<u8>),
|
||||
Reference(Vec<RefItem>),
|
||||
/// An object array (`ObAr`): `count` objects of one class stored column-wise, each item a
|
||||
/// list of `count` values (unit floats).
|
||||
ObjectArray(u32, Descriptor),
|
||||
}
|
||||
|
||||
/// An item of a reference (`obj `).
|
||||
|
|
@ -161,6 +164,10 @@ fn read_value(r: &mut Reader, ty: &[u8; 4], depth: usize) -> Result<DValue> {
|
|||
}
|
||||
DValue::List(v)
|
||||
}
|
||||
b"ObAr" => {
|
||||
let n = r.u32()?;
|
||||
DValue::ObjectArray(n, read_descriptor(r)?)
|
||||
}
|
||||
b"doub" => DValue::Double(r.f64()?),
|
||||
b"UntF" => {
|
||||
let unit = String::from_utf8_lossy(&r.tag()?).into_owned();
|
||||
|
|
@ -280,6 +287,11 @@ fn write_value(out: &mut Vec<u8>, v: &DValue) {
|
|||
write_value(out, x);
|
||||
}
|
||||
}
|
||||
DValue::ObjectArray(n, d) => {
|
||||
out.extend_from_slice(b"ObAr");
|
||||
put_u32(out, *n);
|
||||
write_descriptor(out, d);
|
||||
}
|
||||
DValue::Double(x) => {
|
||||
out.extend_from_slice(b"doub");
|
||||
out.extend_from_slice(&x.to_be_bytes());
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
pub mod descriptor;
|
||||
pub mod engine_data;
|
||||
pub mod placed;
|
||||
mod read;
|
||||
pub mod write;
|
||||
|
||||
|
|
@ -214,6 +215,8 @@ pub struct SmartObject {
|
|||
pub quad: [[f64; 2]; 4],
|
||||
/// The content's size in pixels (`Sz `), when recorded.
|
||||
pub size: Option<[f64; 2]>,
|
||||
/// The placed layer's warp (`warp`), when it has one ([`placed`]).
|
||||
pub warp: Option<placed::Warp>,
|
||||
}
|
||||
|
||||
impl SmartObject {
|
||||
|
|
@ -665,8 +668,11 @@ impl Psd {
|
|||
}
|
||||
};
|
||||
let t = nums("Trnf").filter(|t| t.len() == 8).unwrap_or_else(|| vec![0.0; 8]);
|
||||
// The non-affine transform when present (perspective), else the transform.
|
||||
let t = nums("nonAffineTransform").filter(|t| t.len() == 8).unwrap_or(t);
|
||||
let size = d.obj("Sz ").and_then(|s| Some([s.num("Wdth")?, s.num("Hght")?]));
|
||||
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size });
|
||||
let warp = d.obj("warp").and_then(placed::Warp::read);
|
||||
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size, warp });
|
||||
}
|
||||
b"PlLd" if l.smart_object.is_none() => {
|
||||
let _id = r.tag()?;
|
||||
|
|
@ -681,7 +687,7 @@ impl Psd {
|
|||
for v in &mut t {
|
||||
*v = r.f64()?;
|
||||
}
|
||||
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None });
|
||||
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None, warp: None });
|
||||
}
|
||||
b"vmsk" | b"vsms" => {
|
||||
let _ver = r.u32()?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,410 @@
|
|||
//! Placed-layer (smart object) geometry: the non-affine corner quad (a projective map of the
|
||||
//! content rectangle) and the placed-layer warp (`warp` in `SoLd`: the named warp styles with
|
||||
//! bend and horizontal / vertical distortion, and custom envelope warps whose quilt mesh is a
|
||||
//! grid of bicubic Bézier patches), and a renderer that bakes both into document-space pixels.
|
||||
//!
|
||||
//! The custom mesh is evaluated exactly (bicubic Bézier patches over `meshPoints`, split by
|
||||
//! `quiltSliceX` / `quiltSliceY`). Photoshop does not publish the curves of its named styles;
|
||||
//! the styles here are clean-room geometric definitions of what each name describes (Arc,
|
||||
//! Arch, Bulge, Shell, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist), matched by
|
||||
//! behaviour, not numerically.
|
||||
|
||||
use crate::descriptor::{DValue, Descriptor};
|
||||
use crate::{Pixels, SmartObject};
|
||||
|
||||
/// A placed layer's warp.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Warp {
|
||||
/// `warpNone`, `warpArc`, `warpArcLower`, `warpArcUpper`, `warpArch`, `warpBulge`,
|
||||
/// `warpShellLower`, `warpShellUpper`, `warpFlag`, `warpWave`, `warpFish`, `warpRise`,
|
||||
/// `warpFisheye`, `warpInflate`, `warpSqueeze`, `warpTwist`, `warpCustom`.
|
||||
pub style: String,
|
||||
/// Bend, −100..100 (%).
|
||||
pub bend: f64,
|
||||
/// Horizontal and vertical distortion, −100..100 (%).
|
||||
pub horizontal: f64,
|
||||
pub vertical: f64,
|
||||
/// The style is applied rotated a quarter turn (`warpRotate` = vertical).
|
||||
pub rotate_vertical: bool,
|
||||
/// The warped area in content pixels: left, top, right, bottom.
|
||||
pub bounds: [f64; 4],
|
||||
/// Custom envelope: patch columns / rows and the (3·cols+1)×(3·rows+1) control points
|
||||
/// (content pixels, row-major).
|
||||
pub cols: usize,
|
||||
pub rows: usize,
|
||||
pub mesh: Vec<[f64; 2]>,
|
||||
}
|
||||
|
||||
fn floats(v: Option<&DValue>) -> Vec<f64> {
|
||||
match v {
|
||||
Some(DValue::UnitFloats(_, v)) => v.clone(),
|
||||
Some(DValue::List(l)) => l
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
DValue::Double(v) | DValue::UnitFloat(_, v) => Some(*v),
|
||||
DValue::Integer(i) => Some(*i as f64),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
impl Warp {
|
||||
/// Read a `warp` descriptor; `None` for no warp.
|
||||
pub fn read(d: &Descriptor) -> Option<Warp> {
|
||||
let style = d.enum_value("warpStyle").unwrap_or("warpNone").to_string();
|
||||
let b = d.obj("bounds");
|
||||
let bounds = b
|
||||
.map(|b| [b.num("Left").unwrap_or(0.0), b.num("Top ").unwrap_or(0.0), b.num("Rght").unwrap_or(0.0), b.num("Btom").unwrap_or(0.0)])
|
||||
.unwrap_or([0.0; 4]);
|
||||
let mut w = Warp {
|
||||
bend: d.num("warpValue").unwrap_or(0.0),
|
||||
horizontal: d.num("warpPerspective").unwrap_or(0.0),
|
||||
vertical: d.num("warpPerspectiveOther").unwrap_or(0.0),
|
||||
rotate_vertical: d.enum_value("warpRotate") == Some("Vrtc"),
|
||||
bounds,
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
mesh: vec![],
|
||||
style,
|
||||
};
|
||||
if let Some(env) = d.obj("customEnvelopeWarp") {
|
||||
let cols = floats(env.get("quiltSliceX")).len().saturating_sub(1).max(1);
|
||||
let rows = floats(env.get("quiltSliceY")).len().saturating_sub(1).max(1);
|
||||
if let Some(DValue::ObjectArray(_, pts)) = env.get("meshPoints") {
|
||||
let xs = floats(pts.get("Hrzn"));
|
||||
let ys = floats(pts.get("Vrtc"));
|
||||
let n = (3 * cols + 1) * (3 * rows + 1);
|
||||
if xs.len() == n && ys.len() == n {
|
||||
w.cols = cols;
|
||||
w.rows = rows;
|
||||
w.mesh = xs.into_iter().zip(ys).map(|(x, y)| [x, y]).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
let identity = w.style == "warpNone" || (w.style != "warpCustom" && w.bend == 0.0 && w.horizontal == 0.0 && w.vertical == 0.0);
|
||||
let custom_ok = w.style != "warpCustom" || !w.mesh.is_empty();
|
||||
(!identity && custom_ok).then_some(w)
|
||||
}
|
||||
|
||||
/// The warp of a content point (content pixels → content pixels).
|
||||
pub fn apply(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
|
||||
let [l, t, r, b] = if self.bounds[2] > self.bounds[0] && self.bounds[3] > self.bounds[1] { self.bounds } else { [0.0, 0.0, w, h] };
|
||||
let (bw, bh) = (r - l, b - t);
|
||||
let (u, v) = ((p[0] - l) / bw, (p[1] - t) / bh);
|
||||
if self.style == "warpCustom" {
|
||||
return self.mesh_point(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
|
||||
}
|
||||
// Styles work in a centred frame (pixels), optionally rotated a quarter turn.
|
||||
let (x, y, fw, fh) = if self.rotate_vertical { (bh * (v - 0.5), -bw * (u - 0.5), bh, bw) } else { (bw * (u - 0.5), bh * (v - 0.5), bw, bh) };
|
||||
let [mut x2, mut y2] = style_map(&self.style, self.bend / 100.0, x, y, fw, fh);
|
||||
// Distortions: a perspective-like taper across the width (horizontal) or height.
|
||||
let (hd, vd) = if self.rotate_vertical { (self.vertical / 100.0, self.horizontal / 100.0) } else { (self.horizontal / 100.0, self.vertical / 100.0) };
|
||||
let s = (x2 / (fw / 2.0)).clamp(-1.5, 1.5);
|
||||
let tt = (y2 / (fh / 2.0)).clamp(-1.5, 1.5);
|
||||
y2 *= 1.0 + hd * s;
|
||||
x2 *= 1.0 + vd * tt;
|
||||
let (ox, oy) = if self.rotate_vertical { (-y2, x2) } else { (x2, y2) };
|
||||
[l + bw / 2.0 + ox, t + bh / 2.0 + oy]
|
||||
}
|
||||
|
||||
/// A point of the custom quilt mesh at (u, v) ∈ [0, 1]².
|
||||
fn mesh_point(&self, u: f64, v: f64) -> [f64; 2] {
|
||||
let (cols, rows) = (self.cols, self.rows);
|
||||
let pu = (u * cols as f64).min(cols as f64 - 1e-9);
|
||||
let pv = (v * rows as f64).min(rows as f64 - 1e-9);
|
||||
let (ci, cj) = (pu.floor() as usize, pv.floor() as usize);
|
||||
let (fu, fv) = (pu - ci as f64, pv - cj as f64);
|
||||
let stride = 3 * cols + 1;
|
||||
let bern = |t: f64| {
|
||||
let s = 1.0 - t;
|
||||
[s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t]
|
||||
};
|
||||
let (bu, bv) = (bern(fu), bern(fv));
|
||||
let mut out = [0.0; 2];
|
||||
for j in 0..4 {
|
||||
for i in 0..4 {
|
||||
let q = self.mesh[(3 * cj + j) * stride + 3 * ci + i];
|
||||
let k = bu[i] * bv[j];
|
||||
out[0] += q[0] * k;
|
||||
out[1] += q[1] * k;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A named style in a centred frame `fw`×`fh` (y down), bend `b` ∈ [−1, 1].
|
||||
fn style_map(style: &str, b: f64, x: f64, y: f64, fw: f64, fh: f64) -> [f64; 2] {
|
||||
use std::f64::consts::PI;
|
||||
let (hw, hh) = (fw / 2.0, fh / 2.0);
|
||||
let s = x / hw; // −1..1 across
|
||||
let t = y / hh; // −1..1 down
|
||||
// Concentric arcs: the midline bends through an angle of b·π, rows keep their distance.
|
||||
let arc = |x: f64, y: f64| -> [f64; 2] {
|
||||
let theta = b * PI;
|
||||
if theta.abs() < 1e-9 {
|
||||
return [x, y];
|
||||
}
|
||||
let r = fw / theta;
|
||||
let phi = x / hw * theta / 2.0;
|
||||
let rr = r - y;
|
||||
// Arc centre below the shape for upward bends; keep the midline's centre fixed.
|
||||
[rr * phi.sin(), r - rr * phi.cos()]
|
||||
};
|
||||
let bump = 1.0 - s * s;
|
||||
match style {
|
||||
"warpArc" => arc(x, y),
|
||||
"warpArcLower" => {
|
||||
let a = arc(x, y);
|
||||
let k = (t + 1.0) / 2.0;
|
||||
[x + (a[0] - x) * k, y + (a[1] - y) * k]
|
||||
}
|
||||
"warpArcUpper" => {
|
||||
let a = arc(x, y);
|
||||
let k = (1.0 - t) / 2.0;
|
||||
[x + (a[0] - x) * k, y + (a[1] - y) * k]
|
||||
}
|
||||
"warpArch" => [x, y - b * hh * bump],
|
||||
"warpBulge" => [x, y + t * b * hh * bump],
|
||||
"warpShellLower" => [x * (1.0 - b * 0.25 * (1.0 - t) / 2.0), y + (t + 1.0) / 2.0 * b * hh * bump],
|
||||
"warpShellUpper" => [x * (1.0 - b * 0.25 * (t + 1.0) / 2.0), y - (1.0 - t) / 2.0 * b * hh * bump],
|
||||
"warpFlag" => [x, y - b * hh * 0.5 * (PI * s).sin()],
|
||||
"warpWave" => [x + b * hw * 0.1 * (PI * t).sin(), y - b * hh * 0.5 * (PI * s).sin() * (1.0 - 0.5 * t)],
|
||||
"warpFish" => [x, y + t * b * hh * (PI * (s + 1.0) / 2.0).sin() * (1.0 - 0.5 * (s + 1.0) / 2.0)],
|
||||
"warpRise" => [x, y - b * hh * (PI * s / 2.0).sin()],
|
||||
"warpFisheye" | "warpInflate" => {
|
||||
let k = if style == "warpFisheye" { (1.0 - (s * s + t * t) / 2.0).max(0.0) } else { 1.0 };
|
||||
[x * (1.0 + b * 0.5 * k * (1.0 - t * t)), y * (1.0 + b * 0.5 * k * bump)]
|
||||
}
|
||||
"warpSqueeze" => [x * (1.0 - b * 0.5 * (1.0 - t * t)), y * (1.0 + b * 0.5 * bump)],
|
||||
"warpTwist" => {
|
||||
let r = ((s * s + t * t) / 2.0).sqrt().min(1.0);
|
||||
let a = b * PI / 2.0 * (1.0 - r);
|
||||
let (sn, cs) = a.sin_cos();
|
||||
[x * cs - y * sn, x * sn + y * cs]
|
||||
}
|
||||
_ => [x, y],
|
||||
}
|
||||
}
|
||||
|
||||
/// The projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q`
|
||||
/// (Heckbert's square-to-quad), as a 3×3 matrix applied to (u, v, 1).
|
||||
fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] {
|
||||
let [p0, p1, p2, p3] = q;
|
||||
let sx = p0[0] - p1[0] + p2[0] - p3[0];
|
||||
let sy = p0[1] - p1[1] + p2[1] - p3[1];
|
||||
if sx.abs() < 1e-12 && sy.abs() < 1e-12 {
|
||||
return [[p1[0] - p0[0], p2[0] - p1[0], p0[0]], [p1[1] - p0[1], p2[1] - p1[1], p0[1]], [0.0, 0.0, 1.0]];
|
||||
}
|
||||
let (dx1, dx2) = (p1[0] - p2[0], p3[0] - p2[0]);
|
||||
let (dy1, dy2) = (p1[1] - p2[1], p3[1] - p2[1]);
|
||||
let den = dx1 * dy2 - dx2 * dy1;
|
||||
let g = (sx * dy2 - dx2 * sy) / den;
|
||||
let h = (dx1 * sy - sx * dy1) / den;
|
||||
[[p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]], [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]], [g, h, 1.0]]
|
||||
}
|
||||
|
||||
impl SmartObject {
|
||||
/// Whether the corner quad is a parallelogram (an affine placement).
|
||||
pub fn is_affine(&self) -> bool {
|
||||
let q = self.quad;
|
||||
let ex = q[0][0] + q[2][0] - q[1][0] - q[3][0];
|
||||
let ey = q[0][1] + q[2][1] - q[1][1] - q[3][1];
|
||||
ex.abs() < 1e-3 && ey.abs() < 1e-3
|
||||
}
|
||||
|
||||
/// Whether placing needs baking (a perspective quad or a warp).
|
||||
pub fn needs_bake(&self) -> bool {
|
||||
!self.is_affine() || self.warp.is_some()
|
||||
}
|
||||
|
||||
/// A content point (pixels of a `w`×`h` content) in document pixels: the warp, then the quad.
|
||||
pub fn map(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
|
||||
let p = match &self.warp {
|
||||
Some(wp) => wp.apply(p, w, h),
|
||||
None => p,
|
||||
};
|
||||
let m = square_to_quad(self.quad);
|
||||
let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9));
|
||||
let z = m[2][0] * u + m[2][1] * v + m[2][2];
|
||||
let z = if z.abs() < 1e-12 { 1e-12 } else { z };
|
||||
[(m[0][0] * u + m[0][1] * v + m[0][2]) / z, (m[1][0] * u + m[1][1] * v + m[1][2]) / z]
|
||||
}
|
||||
|
||||
/// The document-space bounds (x0, y0, x1, y1) of the placed (warped) content.
|
||||
pub fn placed_bounds(&self, w: f64, h: f64) -> [f64; 4] {
|
||||
let n = 32;
|
||||
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
let p = self.map([i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h);
|
||||
b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])];
|
||||
}
|
||||
}
|
||||
[b[0].floor(), b[1].floor(), b[2].ceil(), b[3].ceil()]
|
||||
}
|
||||
|
||||
/// The baked size for a `w`×`h` content at `k` pixels per document pixel.
|
||||
pub fn placed_size(&self, w: f64, h: f64, k: f64) -> (u32, u32) {
|
||||
let bb = self.placed_bounds(w, h);
|
||||
(((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32)
|
||||
}
|
||||
|
||||
/// Bake the content (straight RGBA) as placed: warped and mapped by the quad, rendered at
|
||||
/// `k` pixels per document pixel (into `size` pixels when given; see [`Self::placed_size`]).
|
||||
/// Returns the pixels and their document-space origin.
|
||||
pub fn render_placed(&self, content: &Pixels, k: f64, size: Option<(u32, u32)>) -> Option<(Pixels, [f64; 2])> {
|
||||
let (cw, ch) = (content.width as f64, content.height as f64);
|
||||
if content.width == 0 || content.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let bb = self.placed_bounds(cw, ch);
|
||||
let (ow, oh) = match size {
|
||||
Some((w, h)) => (w.max(1), h.max(1)),
|
||||
None => self.placed_size(cw, ch, k),
|
||||
};
|
||||
let (ow, oh) = (ow as usize, oh as usize);
|
||||
// A forward grid, filled triangle by triangle with inverse barycentric sampling.
|
||||
let n = 48usize;
|
||||
let mut dst = Vec::with_capacity((n + 1) * (n + 1));
|
||||
let mut src = Vec::with_capacity((n + 1) * (n + 1));
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
let s = [i as f64 / n as f64 * cw, j as f64 / n as f64 * ch];
|
||||
let d = self.map(s, cw, ch);
|
||||
dst.push([(d[0] - bb[0]) * k, (d[1] - bb[1]) * k]);
|
||||
src.push(s);
|
||||
}
|
||||
}
|
||||
let mut out = vec![[0.0f32; 4]; ow * oh];
|
||||
let sample = |x: f64, y: f64| -> [f32; 4] {
|
||||
let (fx, fy) = ((x - 0.5).clamp(0.0, cw - 1.0), (y - 0.5).clamp(0.0, ch - 1.0));
|
||||
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
|
||||
let (x1, y1) = ((x0 + 1).min(content.width as usize - 1), (y0 + 1).min(content.height as usize - 1));
|
||||
let (a, b) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32);
|
||||
let at = |x: usize, y: usize| {
|
||||
let p = content.data[y * content.width as usize + x];
|
||||
[p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]
|
||||
};
|
||||
let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1));
|
||||
let mut o = [0.0; 4];
|
||||
for c in 0..4 {
|
||||
o[c] = (p00[c] * (1.0 - a) + p10[c] * a) * (1.0 - b) + (p01[c] * (1.0 - a) + p11[c] * a) * b;
|
||||
}
|
||||
o
|
||||
};
|
||||
let idx = |i: usize, j: usize| j * (n + 1) + i;
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
for tri in [[idx(i, j), idx(i + 1, j), idx(i + 1, j + 1)], [idx(i, j), idx(i + 1, j + 1), idx(i, j + 1)]] {
|
||||
let [a, b, c] = tri.map(|t| dst[t]);
|
||||
let den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
|
||||
if den.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let x0 = a[0].min(b[0]).min(c[0]).floor().max(0.0) as usize;
|
||||
let x1 = (a[0].max(b[0]).max(c[0]).ceil() as usize).min(ow);
|
||||
let y0 = a[1].min(b[1]).min(c[1]).floor().max(0.0) as usize;
|
||||
let y1 = (a[1].max(b[1]).max(c[1]).ceil() as usize).min(oh);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let l1 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / den;
|
||||
let l2 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / den;
|
||||
let l3 = 1.0 - l1 - l2;
|
||||
if l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9 {
|
||||
continue;
|
||||
}
|
||||
let [sa, sb, sc] = tri.map(|t| src[t]);
|
||||
let sx = sa[0] * l1 + sb[0] * l2 + sc[0] * l3;
|
||||
let sy = sa[1] * l1 + sb[1] * l2 + sc[1] * l3;
|
||||
out[y * ow + x] = sample(sx, sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Back to straight alpha.
|
||||
for p in &mut out {
|
||||
if p[3] > 0.0 {
|
||||
for c in 0..3 {
|
||||
p[c] /= p[3];
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((Pixels { width: ow as u32, height: oh as u32, data: out }, [bb[0], bb[1]]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn so(quad: [[f64; 2]; 4], warp: Option<Warp>) -> SmartObject {
|
||||
SmartObject { uuid: "x".into(), quad, size: Some([20.0, 10.0]), warp }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perspective_quad_maps_corners_and_is_projective() {
|
||||
let q = [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]];
|
||||
let s = so(q, None);
|
||||
assert!(!s.is_affine() && s.needs_bake());
|
||||
for (c, want) in [([0.0, 0.0], q[0]), ([20.0, 0.0], q[1]), ([20.0, 10.0], q[2]), ([0.0, 10.0], q[3])] {
|
||||
let p = s.map(c, 20.0, 10.0);
|
||||
assert!((p[0] - want[0]).abs() < 1e-9 && (p[1] - want[1]).abs() < 1e-9, "{c:?} → {p:?}");
|
||||
}
|
||||
// Projective, not bilinear: the content centre is where the diagonals cross.
|
||||
let c = s.map([10.0, 5.0], 20.0, 10.0);
|
||||
assert!((c[0] - 30.0).abs() > 0.5, "{c:?}");
|
||||
let affine = so([[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]], None);
|
||||
assert!(affine.is_affine() && !affine.needs_bake());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_mesh_and_styles_warp() {
|
||||
// An identity 4×4 mesh shifted 3 px right in its interior row: interior points move.
|
||||
let mut mesh = vec![];
|
||||
for j in 0..4 {
|
||||
for i in 0..4 {
|
||||
mesh.push([i as f64 * 20.0 / 3.0 + if j == 1 || j == 2 { 3.0 } else { 0.0 }, j as f64 * 10.0 / 3.0]);
|
||||
}
|
||||
}
|
||||
let w = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, mesh, ..Default::default() };
|
||||
let p = w.apply([10.0, 5.0], 20.0, 10.0);
|
||||
assert!((p[0] - 12.25).abs() < 1e-9 && (p[1] - 5.0).abs() < 1e-9, "{p:?}");
|
||||
assert_eq!(w.apply([0.0, 0.0], 20.0, 10.0), [0.0, 0.0]);
|
||||
// Arc: the top centre rises, the bottom centre too (concentric arcs); Bulge: top up,
|
||||
// bottom down; Flag keeps the centre.
|
||||
let style = |s: &str| Warp { style: s.into(), bend: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() };
|
||||
let top = style("warpArc").apply([100.0, 0.0], 200.0, 100.0);
|
||||
let corner = style("warpArc").apply([0.0, 0.0], 200.0, 100.0);
|
||||
assert!(top[1] < corner[1], "arc {top:?} {corner:?}");
|
||||
let b = style("warpBulge");
|
||||
assert!(b.apply([100.0, 0.0], 200.0, 100.0)[1] < 0.0 && b.apply([100.0, 100.0], 200.0, 100.0)[1] > 100.0);
|
||||
assert!((style("warpFlag").apply([100.0, 50.0], 200.0, 100.0)[1] - 50.0).abs() < 1e-9);
|
||||
// Vertical rotation swaps the roles of the axes.
|
||||
let mut v = style("warpBulge");
|
||||
v.rotate_vertical = true;
|
||||
let l = v.apply([0.0, 50.0], 200.0, 100.0);
|
||||
assert!(l[0] < 0.0 && (l[1] - 50.0).abs() < 1e-9, "{l:?}");
|
||||
// Horizontal distortion: the right side grows taller.
|
||||
let d = Warp { style: "warpArc".into(), horizontal: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() };
|
||||
assert!(d.apply([200.0, 0.0], 200.0, 100.0)[1] < d.apply([0.0, 0.0], 200.0, 100.0)[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_placed_bakes_a_perspective_quad() {
|
||||
// 2×1 content: red | green, pinned to a trapezoid.
|
||||
let content = Pixels { width: 2, height: 1, data: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]] };
|
||||
let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [40.0, 5.0], [40.0, 15.0], [0.0, 20.0]], size: None, warp: None };
|
||||
let (px, origin) = s.render_placed(&content, 1.0, None).unwrap();
|
||||
assert_eq!(origin, [0.0, 0.0]);
|
||||
assert_eq!((px.width, px.height), (40, 20));
|
||||
let at = |x: usize, y: usize| px.data[y * 40 + x];
|
||||
assert!(at(5, 10)[0] > 0.9 && at(5, 10)[3] > 0.99, "{:?}", at(5, 10));
|
||||
assert!(at(35, 10)[1] > 0.9, "{:?}", at(35, 10));
|
||||
assert_eq!(at(38, 1)[3], 0.0, "outside the trapezoid");
|
||||
}
|
||||
}
|
||||
|
|
@ -414,14 +414,53 @@ fn linked_block(files: &[(String, String, [u8; 4], Vec<u8>)]) -> Vec<u8> {
|
|||
/// `SoLd`: a placed layer showing linked file `uuid` (content `size` pixels) with its corners at
|
||||
/// `quad` (top-left, top-right, bottom-right, bottom-left).
|
||||
pub fn smart_object_block(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2]) -> ([u8; 4], Vec<u8>) {
|
||||
smart_object_block_warped(uuid, quad, size, None)
|
||||
}
|
||||
|
||||
/// A `warp` descriptor: a named style (bend and distortions in %) over `bounds` (left, top,
|
||||
/// right, bottom), or with `mesh` (16 points, content pixels) a custom envelope.
|
||||
pub fn warp_descriptor(style: &str, bend: f64, h: f64, v: f64, bounds: [f64; 4], mesh: Option<&[[f64; 2]]>) -> Descriptor {
|
||||
let mut d = Descriptor::new("warp")
|
||||
.with("warpStyle", DValue::Enum("warpStyle".into(), style.into()))
|
||||
.with("warpValue", DValue::Double(bend))
|
||||
.with("warpPerspective", DValue::Double(h))
|
||||
.with("warpPerspectiveOther", DValue::Double(v))
|
||||
.with("warpRotate", DValue::Enum("Ornt".into(), "Hrzn".into()))
|
||||
.with(
|
||||
"bounds",
|
||||
DValue::Descriptor(
|
||||
Descriptor::new("Rctn")
|
||||
.with("Top ", DValue::UnitFloat("#Pxl".into(), bounds[1]))
|
||||
.with("Left", DValue::UnitFloat("#Pxl".into(), bounds[0]))
|
||||
.with("Btom", DValue::UnitFloat("#Pxl".into(), bounds[3]))
|
||||
.with("Rght", DValue::UnitFloat("#Pxl".into(), bounds[2])),
|
||||
),
|
||||
)
|
||||
.with("uOrder", DValue::Integer(4))
|
||||
.with("vOrder", DValue::Integer(4));
|
||||
if let Some(m) = mesh {
|
||||
let pts = Descriptor::new("rationalPoint")
|
||||
.with("Hrzn", DValue::UnitFloats("#Pxl".into(), m.iter().map(|p| p[0]).collect()))
|
||||
.with("Vrtc", DValue::UnitFloats("#Pxl".into(), m.iter().map(|p| p[1]).collect()));
|
||||
d = d
|
||||
.with("customEnvelopeWarp", DValue::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", DValue::ObjectArray(m.len() as u32, pts))));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// `SoLd` with an optional warp; `quad` may be non-affine (perspective).
|
||||
pub fn smart_object_block_warped(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2], warp: Option<Descriptor>) -> ([u8; 4], Vec<u8>) {
|
||||
let list = |v: &[f64]| DValue::List(v.iter().map(|x| DValue::Double(*x)).collect());
|
||||
let t: Vec<f64> = quad.iter().flat_map(|p| [p[0], p[1]]).collect();
|
||||
let d = Descriptor::new("null")
|
||||
let mut d = Descriptor::new("null")
|
||||
.with("Idnt", DValue::Text(uuid.to_string()))
|
||||
.with("placed", DValue::Text(uuid.to_string()))
|
||||
.with("Trnf", list(&t))
|
||||
.with("nonAffineTransform", list(&t))
|
||||
.with("Sz ", DValue::Descriptor(Descriptor::new("Pnt ").with("Wdth", DValue::Double(size[0])).with("Hght", DValue::Double(size[1]))));
|
||||
if let Some(w) = warp {
|
||||
d = d.with("warp", DValue::Descriptor(w));
|
||||
}
|
||||
let mut out = b"soLD".to_vec();
|
||||
u32b(&mut out, 4);
|
||||
u32b(&mut out, 16);
|
||||
|
|
|
|||
Loading…
Reference in New Issue