mirror of
https://github.com/storytold/texturecraft.git
synced 2026-11-06 18:42:19 +00:00
b133186641
The History panel lists every undone step too (dimmed, next first) and
a click on any step goes back or forward to it, the current one
highlighted. edit.undo and edit.redo take {steps?} for the same jump.
383 lines
19 KiB
Rust
383 lines
19 KiB
Rust
use serde_json::{Value, json};
|
|
use texturecraft_engine::core::Channel;
|
|
use texturecraft_engine::geom::primitives;
|
|
use texturecraft_engine::{Session, command_specs, execute};
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new_project("t", primitives::sphere(1.0, 48, 24), "builtin:sphere", None, 256).unwrap();
|
|
s.viewport = texturecraft_engine::geom::glam::Vec2::new(400.0, 400.0);
|
|
s.camera.yaw = 0.0;
|
|
s.camera.pitch = 0.0;
|
|
s.update();
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn scripted_stroke_paints_and_undo_restores() {
|
|
let mut s = session();
|
|
execute(&mut s, "tool.material", &json!({"channel": "baseColor", "color": [1.0, 0.0, 0.0]})).unwrap();
|
|
execute(&mut s, "tool.brush", &json!({"size": 20, "hardness": 90})).unwrap();
|
|
let r = execute(&mut s, "tool.stroke", &json!({"points": [[180, 200], [220, 200]]})).unwrap();
|
|
assert!(r["stamps"].as_u64().unwrap() > 1);
|
|
// The texel under the screen center is now red.
|
|
let hit = s.pick(texturecraft_engine::geom::glam::Vec2::new(200.0, 200.0), s.viewport).unwrap();
|
|
let res = 256.0;
|
|
let (x, y) = ((hit.uv.x * res) as u32, (hit.uv.y * res) as u32);
|
|
let v = s.caches[0].composite.value(Channel::BaseColor, x, y);
|
|
assert!(v[0] > 0.9 && v[1] < 0.1, "{v:?}");
|
|
execute(&mut s, "edit.undo", &Value::Null).unwrap();
|
|
s.update();
|
|
let v = s.caches[0].composite.value(Channel::BaseColor, x, y);
|
|
assert!((v[0] - 0.5).abs() < 0.01, "{v:?}");
|
|
execute(&mut s, "edit.redo", &Value::Null).unwrap();
|
|
s.update();
|
|
assert!(s.caches[0].composite.value(Channel::BaseColor, x, y)[0] > 0.9);
|
|
}
|
|
|
|
#[test]
|
|
fn layers_masks_materials_and_bake() {
|
|
let mut s = session();
|
|
execute(&mut s, "layer.add.fill", &json!({"color": "#336699", "roughness": 0.2})).unwrap();
|
|
execute(&mut s, "mask.add.black", &Value::Null).unwrap();
|
|
execute(&mut s, "effect.add.generator", &json!({"generator": "position", "level": 0.5})).unwrap();
|
|
execute(&mut s, "bake.run", &json!({"resolution": 128, "rays": 8})).unwrap();
|
|
execute(&mut s, "material.apply", &json!({"material": "steel_painted_worn"})).unwrap();
|
|
execute(&mut s, "layer.group", &Value::Null).unwrap();
|
|
execute(&mut s, "layer.duplicate", &Value::Null).unwrap();
|
|
let inspect = execute(&mut s, "project.inspect", &Value::Null).unwrap();
|
|
assert!(inspect["layers"].as_array().unwrap().len() >= 3, "{inspect}");
|
|
s.doc.project.validate().unwrap();
|
|
s.update();
|
|
for _ in 0..7 {
|
|
execute(&mut s, "edit.undo", &Value::Null).unwrap();
|
|
}
|
|
assert_eq!(s.doc.project.walk(0).len(), 1, "back to the initial paint layer");
|
|
}
|
|
|
|
#[test]
|
|
fn layer_rename_and_undo() {
|
|
let mut s = session();
|
|
let layer_id = s.selected_layer().unwrap();
|
|
let orig_name = s.doc.project.layer(layer_id).unwrap().name.clone();
|
|
execute(&mut s, "layer.rename", &json!({"name": "Custom Paint"})).unwrap();
|
|
assert_eq!(s.doc.project.layer(layer_id).unwrap().name, "Custom Paint");
|
|
|
|
execute(&mut s, "edit.undo", &Value::Null).unwrap();
|
|
assert_eq!(s.doc.project.layer(layer_id).unwrap().name, orig_name);
|
|
|
|
execute(&mut s, "edit.redo", &Value::Null).unwrap();
|
|
assert_eq!(s.doc.project.layer(layer_id).unwrap().name, "Custom Paint");
|
|
}
|
|
|
|
#[test]
|
|
fn undo_and_redo_jump_several_steps() {
|
|
let mut s = session();
|
|
let layer = s.selected_layer().unwrap();
|
|
let name = |s: &Session| s.doc.project.layer(layer).unwrap().name.clone();
|
|
let first = name(&s);
|
|
for n in ["A", "B", "C"] {
|
|
execute(&mut s, "layer.rename", &json!({"name": n})).unwrap();
|
|
}
|
|
let base = s.history_labels().len() - 3;
|
|
// Two back to "A": the undone steps wait to be redone, next first.
|
|
assert_eq!(execute(&mut s, "edit.undo", &json!({"steps": 2})).unwrap(), json!({"undone": "Rename layer", "steps": 2}));
|
|
assert_eq!(name(&s), "A");
|
|
assert_eq!(s.redo_labels().len(), 2);
|
|
assert_eq!(execute(&mut s, "edit.history", &Value::Null).unwrap()["redo"].as_array().unwrap().len(), 2);
|
|
// Forward past the end stops at "C"; back past the start stops at the first state.
|
|
assert_eq!(execute(&mut s, "edit.redo", &json!({"steps": 99})).unwrap()["steps"], 2);
|
|
assert_eq!(name(&s), "C");
|
|
let r = execute(&mut s, "edit.undo", &json!({"steps": 1_000_000})).unwrap();
|
|
assert_eq!(r["steps"].as_u64().unwrap() as usize, base + 3);
|
|
assert_eq!(name(&s), first);
|
|
assert!(execute(&mut s, "edit.undo", &json!({"steps": 2})).is_err(), "nothing left to undo");
|
|
}
|
|
|
|
#[test]
|
|
fn polygon_fill_and_color_picker() {
|
|
let mut s = session();
|
|
execute(&mut s, "tool.material", &json!({"channel": "roughness", "value": 0.9})).unwrap();
|
|
execute(&mut s, "tool.polygonFill", &Value::Null).unwrap();
|
|
execute(&mut s, "tool.fillMode", &json!({"mode": "mesh"})).unwrap();
|
|
execute(&mut s, "tool.click", &json!({"x": 200, "y": 200})).unwrap();
|
|
execute(&mut s, "tool.colorPicker", &Value::Null).unwrap();
|
|
let r = execute(&mut s, "tool.click", &json!({"x": 200, "y": 200})).unwrap();
|
|
assert!((r["roughness"][0].as_f64().unwrap() - 0.9).abs() < 0.01, "{r}");
|
|
}
|
|
|
|
/// Every command must return Ok or Err — never panic — for hostile params.
|
|
#[test]
|
|
fn panic_hunt() {
|
|
let hostile = [
|
|
Value::Null,
|
|
json!({}),
|
|
json!({"layer": u64::MAX, "ts": 99, "channel": "nope", "mode": "???", "opacity": f64::MAX}),
|
|
json!({"layer": 1, "index": -5, "delta": i64::MIN, "above": "x", "points": [[f64::MAX, -1e300]], "size": -1e30}),
|
|
json!({"resolution": 0, "maps": ["x"], "rays": u64::MAX, "antialiasing": 3, "color": [1], "value": "nan"}),
|
|
json!({"effect": 0, "x": 1e30, "y": -1e30, "viewport": [0, 0], "mesh": "../../etc", "material": ""}),
|
|
];
|
|
for spec in command_specs() {
|
|
if spec.id == "project.sample" {
|
|
continue; // slow (bakes); covered elsewhere
|
|
}
|
|
for p in &hostile {
|
|
let mut s = session();
|
|
let _ = execute(&mut s, spec.id, p);
|
|
s.update();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn copy_paste_flatten_and_resize_keep_the_look() {
|
|
let mut s = Session::new_project("t", primitives::material_ball(), "builtin:material_ball", None, 256).unwrap();
|
|
s.viewport = texturecraft_engine::geom::glam::Vec2::new(400.0, 400.0);
|
|
execute(&mut s, "layer.add.fill", &json!({"color": [0.9, 0.1, 0.1], "roughness": 0.3})).unwrap();
|
|
execute(&mut s, "mask.add.black", &Value::Null).unwrap();
|
|
execute(&mut s, "effect.add.fill", &json!({"procedural": "checker", "scale": 4})).unwrap();
|
|
s.update();
|
|
let before: Vec<[f32; 3]> = (0..256).step_by(9).map(|x| s.caches[0].composite.value(Channel::BaseColor, x, 100)).collect();
|
|
// Flatten keeps the composite identical (within 8-bit-ish tolerance) and makes a paint layer.
|
|
let id = s.selected_layer().unwrap();
|
|
execute(&mut s, "layer.flatten", &json!({"layer": id.0})).unwrap();
|
|
s.update();
|
|
let after: Vec<[f32; 3]> = (0..256).step_by(9).map(|x| s.caches[0].composite.value(Channel::BaseColor, x, 100)).collect();
|
|
for (a, b) in before.iter().zip(&after) {
|
|
for k in 0..3 {
|
|
assert!((a[k] - b[k]).abs() < 2e-3, "{a:?} {b:?}");
|
|
}
|
|
}
|
|
assert!(matches!(s.doc.project.layer(id).unwrap().kind, texturecraft_engine::core::LayerKind::Paint));
|
|
// Copy into the other texture set (different resolution).
|
|
execute(&mut s, "textureSet.resolution", &json!({"ts": 1, "resolution": 128})).unwrap();
|
|
execute(&mut s, "layer.copy", &Value::Null).unwrap();
|
|
execute(&mut s, "textureSet.select", &json!({"ts": 1})).unwrap();
|
|
let r = execute(&mut s, "layer.paste", &Value::Null).unwrap();
|
|
let pasted = texturecraft_engine::core::LayerId(r["layer"].as_u64().unwrap());
|
|
assert_eq!(s.doc.pixels.layer(pasted, Channel::BaseColor).unwrap().width(), 128);
|
|
// Resizing resamples instead of clearing.
|
|
execute(&mut s, "textureSet.resolution", &json!({"ts": 0, "resolution": 512})).unwrap();
|
|
assert_eq!(s.doc.pixels.layer(id, Channel::BaseColor).unwrap().width(), 512);
|
|
s.update();
|
|
let big = s.caches[0].composite.value(Channel::BaseColor, 2 * 9 * 5, 200);
|
|
let small = before[5];
|
|
assert!((big[0] - small[0]).abs() < 0.2, "{big:?} {small:?}");
|
|
execute(&mut s, "layer.instantiateAll", &json!({"layer": id.0})).unwrap();
|
|
s.doc.project.validate().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn image_resources_fill_layers_and_round_trip() {
|
|
let mut s = session();
|
|
let mut png = Vec::new();
|
|
let img = image::RgbaImage::from_fn(8, 8, |_, _| image::Rgba([255, 0, 0, 255]));
|
|
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
|
let name = s.import_resource_bytes("C:/x/red bricks.png", png).unwrap();
|
|
assert_eq!(name, "red_bricks.png");
|
|
let r = execute(&mut s, "layer.add.image", &json!({"name": name})).unwrap();
|
|
assert!(r["layer"].as_u64().is_some());
|
|
s.update();
|
|
let v = s.caches[0].composite.value(Channel::BaseColor, 128, 128);
|
|
assert!(v[0] > 0.95 && v[1] < 0.05, "{v:?}");
|
|
let bytes = s.to_project_bytes().unwrap();
|
|
let back = Session::from_project_bytes(&bytes).unwrap();
|
|
assert_eq!(back.doc.resources.len(), 1);
|
|
assert_eq!(back.images.len(), 1);
|
|
execute(&mut s, "edit.undo", &Value::Null).unwrap();
|
|
execute(&mut s, "edit.undo", &Value::Null).unwrap();
|
|
assert!(s.images.is_empty(), "undoing the import forgets the decoded image");
|
|
assert!(s.import_resource_bytes("bad.png", vec![1, 2, 3]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn brush_presets_apply_save_and_load() {
|
|
let mut s = session();
|
|
let list = execute(&mut s, "brush.preset.list", &Value::Null).unwrap();
|
|
assert!(list.as_array().unwrap().len() >= 12);
|
|
execute(&mut s, "tool.brush", &json!({"size": 30})).unwrap();
|
|
let b = execute(&mut s, "brush.preset.apply", &json!({"preset": "Scratches"})).unwrap();
|
|
assert_eq!((b["alpha"]["library"].as_str(), b["followPath"].as_bool()), (Some("scratches"), Some(true)));
|
|
assert!((b["size"].as_f64().unwrap() - 30.0).abs() < 1e-3, "presets keep the size");
|
|
assert!(execute(&mut s, "tool.stroke", &json!({"points": [[160, 200], [240, 200]]})).unwrap()["stamps"].as_u64().unwrap() > 0);
|
|
// Saved and loaded back onto another brush, the stroke returns and the size stays.
|
|
let path = std::env::temp_dir().join(format!("tc-brush-{}.tcbrush", std::process::id()));
|
|
let path = path.to_str().unwrap();
|
|
execute(&mut s, "brush.preset.save", &json!({"path": path})).unwrap();
|
|
execute(&mut s, "brush.preset.apply", &json!({"preset": "basic_soft"})).unwrap();
|
|
execute(&mut s, "tool.brush", &json!({"size": 10})).unwrap();
|
|
let b = execute(&mut s, "brush.preset.load", &json!({"path": path})).unwrap();
|
|
assert_eq!(b["alpha"]["library"], "scratches");
|
|
assert!((b["size"].as_f64().unwrap() - 10.0).abs() < 1e-3);
|
|
std::fs::write(path, b"{}").unwrap();
|
|
assert!(execute(&mut s, "brush.preset.load", &json!({"path": path})).is_err());
|
|
let _ = std::fs::remove_file(path);
|
|
for bad in [json!({}), json!({"preset": "nope"})] {
|
|
assert!(execute(&mut s, "brush.preset.apply", &bad).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn text_becomes_a_brush_alpha_and_a_decal() {
|
|
let mut s = session();
|
|
let fonts = execute(&mut s, "text.fonts", &Value::Null).unwrap();
|
|
assert_eq!(fonts["builtIn"][0], "Ubuntu Light");
|
|
let r = execute(&mut s, "resource.text", &json!({"text": "TEXT\nCRAFT", "font": "ubuntu light", "size": 64})).unwrap();
|
|
let name = r["name"].as_str().unwrap().to_string();
|
|
assert_eq!(name, "Text_TEXT.png");
|
|
assert!(r["width"].as_u64().unwrap() > r["height"].as_u64().unwrap() / 2, "two short lines");
|
|
// White with the glyphs in alpha.
|
|
let img = s.images[&name].clone();
|
|
assert!(img.rgba.iter().any(|t| t[3] > 0.99) && img.rgba.iter().any(|t| t[3] == 0.0) && img.rgba.iter().all(|t| t[0] == 1.0));
|
|
// As the brush alpha it paints (keeping its proportions), and it places as a decal.
|
|
execute(&mut s, "tool.material", &json!({"channel": "baseColor", "color": [1.0, 0.0, 0.0]})).unwrap();
|
|
execute(&mut s, "tool.brush", &json!({"size": 40, "alpha": {"image": name}})).unwrap();
|
|
assert!(execute(&mut s, "tool.click", &json!({"x": 200, "y": 200})).is_ok());
|
|
assert!(execute(&mut s, "layer.add.decal", &json!({"image": name})).unwrap()["layer"].as_u64().is_some());
|
|
for bad in [json!({}), json!({"text": " "}), json!({"text": "x", "font": "No Such Font"})] {
|
|
assert!(execute(&mut s, "resource.text", &bad).is_err(), "{bad}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn projection_tool_paints_the_stencil_colors() {
|
|
let mut s = session();
|
|
let mut png = Vec::new();
|
|
let img = image::RgbaImage::from_fn(16, 16, |x, _| if x < 8 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 255, 0, 255]) });
|
|
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
|
let name = s.import_resource_bytes("split.png", png).unwrap();
|
|
execute(&mut s, "tool.projection", &Value::Null).unwrap();
|
|
// Without a stencil the tool explains what's missing.
|
|
assert!(execute(&mut s, "tool.stroke", &json!({"points": [[150, 200]]})).is_err());
|
|
execute(&mut s, "tool.stencil", &json!({"image": name, "scale": 1.0, "tile": true})).unwrap();
|
|
execute(&mut s, "tool.brush", &json!({"size": 6, "hardness": 90})).unwrap();
|
|
for x in [150.0f32, 250.0] {
|
|
execute(&mut s, "tool.stroke", &json!({"points": [[x, 200.0]]})).unwrap();
|
|
}
|
|
let vp = s.viewport;
|
|
let at = |s: &Session, x: f32| {
|
|
let hit = s.pick(texturecraft_engine::geom::glam::Vec2::new(x, 200.0), vp).unwrap();
|
|
s.caches[0].composite.value(Channel::BaseColor, (hit.uv.x * 256.0) as u32, (hit.uv.y * 256.0) as u32)
|
|
};
|
|
let left = at(&s, 150.0);
|
|
let right = at(&s, 250.0);
|
|
assert!(left[0] > 0.8 && left[1] < 0.2, "left {left:?}");
|
|
assert!(right[1] > 0.8 && right[0] < 0.2, "right {right:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn smart_materials_save_and_load_with_pixels() {
|
|
let mut s = Session::new_project("t", primitives::material_ball(), "builtin:material_ball", None, 256).unwrap();
|
|
s.viewport = texturecraft_engine::geom::glam::Vec2::new(400.0, 400.0);
|
|
let group = texturecraft_engine::materials::apply(&mut s, "steel_painted_worn").unwrap();
|
|
let before = s.doc.project.walk(0).len();
|
|
let bytes = s.smart_material_bytes(group, "My Steel").unwrap();
|
|
execute(&mut s, "textureSet.select", &json!({"ts": 1})).unwrap();
|
|
let id = s.add_smart_material_bytes(&bytes).unwrap();
|
|
assert_eq!(s.doc.project.layer(id).unwrap().name, "My Steel");
|
|
let walked = s.doc.project.walk(1).len();
|
|
assert_eq!(walked, 1 + (before - 1), "the folder and its children arrive");
|
|
assert!(s.add_smart_material_bytes(b"junk").is_err());
|
|
s.doc.project.validate().unwrap();
|
|
}
|
|
|
|
/// Strokes drawn frame by frame (paint, erase, a mask) commit their staged tiles, which then
|
|
/// settle at low priority to exactly what a full recomposite gives; undo restores.
|
|
#[test]
|
|
fn committed_strokes_settle_to_the_full_composite() {
|
|
use texturecraft_engine::geom::glam::Vec2;
|
|
let mut s = Session::new_project("t", primitives::sphere(1.0, 48, 24), "builtin:sphere", None, 2048).unwrap();
|
|
s.viewport = Vec2::new(400.0, 400.0);
|
|
execute(&mut s, "layer.add.fill", &json!({"color": [0.2, 0.5, 0.3], "roughness": 0.7})).unwrap();
|
|
execute(&mut s, "layer.add.paint", &Value::Null).unwrap();
|
|
execute(&mut s, "tool.brush", &json!({"size": 25, "hardness": 70, "opacity": 0.8})).unwrap();
|
|
s.update();
|
|
let full = |s: &mut Session| {
|
|
s.caches[0].invalidate_all();
|
|
s.update();
|
|
s.caches[0].composite.clone()
|
|
};
|
|
let original = full(&mut s);
|
|
for step in ["paint", "eraser", "mask"] {
|
|
match step {
|
|
"eraser" => drop(execute(&mut s, "tool.select", &json!({"tool": "eraser"})).unwrap()),
|
|
"mask" => {
|
|
execute(&mut s, "tool.select", &json!({"tool": "paint"})).unwrap();
|
|
execute(&mut s, "mask.add.white", &Value::Null).unwrap();
|
|
s.target = texturecraft_engine::StackTarget::Mask;
|
|
s.tool.mask_value = 0.2;
|
|
}
|
|
_ => {}
|
|
}
|
|
s.update();
|
|
s.begin_stroke(Vec2::new(120.0, 200.0), s.viewport, 1.0).unwrap();
|
|
for i in 1..30 {
|
|
s.continue_stroke(Vec2::new(120.0 + i as f32 * 6.0, 200.0 + (i as f32 * 0.4).sin() * 40.0), 1.0);
|
|
s.update();
|
|
}
|
|
s.end_stroke().unwrap();
|
|
// Nothing was left dirty: the committed tiles only settle, a slice per budgeted update.
|
|
assert!(s.caches[0].dirty.is_empty(), "{step}");
|
|
let settling = s.caches[0].settle.len();
|
|
assert!(settling > 32, "{step}: {settling} tiles settle");
|
|
let t = std::cell::Cell::new(0.0);
|
|
let clock = || {
|
|
t.set(t.get() + 2.0);
|
|
t.get()
|
|
};
|
|
s.update_for(30.0, clock);
|
|
let left = s.pending_tiles();
|
|
assert!(left > 0 && left < settling, "{step}: {left} of {settling} left after one slice");
|
|
s.update();
|
|
let settled = s.caches[0].composite.clone();
|
|
assert_eq!(settled, full(&mut s), "{step}: settled tiles match a full recomposite");
|
|
}
|
|
// Mask stroke, white mask, eraser and paint strokes.
|
|
for _ in 0..4 {
|
|
execute(&mut s, "edit.undo", &Value::Null).unwrap();
|
|
}
|
|
assert_eq!(full(&mut s), original);
|
|
}
|
|
|
|
/// A resize prepared from a snapshot and applied later matches the command, keeps edits made
|
|
/// while it ran, and undoes in one step.
|
|
#[test]
|
|
fn background_resizes_match_the_command_and_keep_later_edits() {
|
|
use texturecraft_engine::bake::Progress;
|
|
let stroke = |s: &mut Session, y: u32| {
|
|
execute(s, "tool.stroke", &json!({"points": [[150, y], [250, y]]})).unwrap();
|
|
};
|
|
let (mut a, mut b) = (session(), session());
|
|
for s in [&mut a, &mut b] {
|
|
execute(s, "tool.brush", &json!({"size": 10})).unwrap();
|
|
stroke(s, 200);
|
|
}
|
|
execute(&mut a, "textureSet.resolution", &json!({"resolution": 512})).unwrap();
|
|
let task = b.resize_task(0, 512).unwrap();
|
|
let progress = Progress::new();
|
|
let out = task.run(&progress).unwrap();
|
|
assert_eq!(progress.fraction(), 1.0);
|
|
assert_eq!(out.target(), (0, 512));
|
|
b.apply_resize(out).unwrap();
|
|
assert_eq!(a.doc.pixels.layers, b.doc.pixels.layers);
|
|
a.update();
|
|
b.update();
|
|
assert_eq!(a.caches[0].composite, b.caches[0].composite);
|
|
assert_eq!(b.caches[0].size, (512, 512));
|
|
// A stroke painted while the resize ran is kept (the resize runs again on apply).
|
|
let out = b.resize_task(0, 1024).unwrap().run(&Progress::new()).unwrap();
|
|
stroke(&mut b, 260);
|
|
let painted = |s: &Session| s.doc.pixels.layers.values().map(|i| i.allocated_count()).sum::<usize>();
|
|
let before = painted(&b);
|
|
b.apply_resize(out).unwrap();
|
|
assert_eq!(b.doc.project.texture_sets[0].resolution, 1024);
|
|
stroke(&mut a, 260);
|
|
execute(&mut a, "textureSet.resolution", &json!({"resolution": 1024})).unwrap();
|
|
assert_eq!(a.doc.pixels.layers, b.doc.pixels.layers);
|
|
assert!(painted(&b) > before);
|
|
assert_eq!(b.undo_label(), Some("Texture set size"));
|
|
execute(&mut b, "edit.undo", &Value::Null).unwrap();
|
|
assert_eq!((b.doc.project.texture_sets[0].resolution, painted(&b)), (512, before));
|
|
b.update();
|
|
assert_eq!(b.caches[0].size, (512, 512));
|
|
}
|