mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
The issue asked for a vector halftone that turns art or an image into vector dots, lines or shapes by tone, with angle, frequency, shape, mono or CMYK separations and clipping to the object. Nothing like it existed. object.vectorHalftone renders the selection offscreen, samples its tone once per cell of a grid turned by the screen angle and places a shape whose area follows the tone (circle, ellipse, square, diamond or line). Mono screens follow lightness in one colour; CMYK screens follow the ink amounts the CMYK output separates, one screen per ink at the classic angles, multiplied over each other. Dots are clipped to the art's outline as real geometry (only the dots crossing it go through a boolean). It is a command rather than a live effect: the live pipeline can't sample rendered tone when exporting. Its dialog previews live and OK keeps it as one undo step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
6.2 KiB
Rust
129 lines
6.2 KiB
Rust
//! The colour adjustment effect dialogs (Effect → Color Adjustments) and Object → Vector Halftone
|
|
//! in the menus.
|
|
|
|
use serde_json::json;
|
|
use vectorcraft_engine::Session;
|
|
|
|
use crate::{VectorcraftApp, dialogs, menus, tests_labels::painted_text};
|
|
|
|
fn app() -> VectorcraftApp {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
|
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
|
app
|
|
}
|
|
|
|
fn dialog_text(app: &mut VectorcraftApp) -> String {
|
|
painted_text(app, |app, ui| dialogs::show(app, ui.ctx()))
|
|
}
|
|
|
|
#[test]
|
|
fn the_effect_menu_lists_the_adjustments_and_the_object_menu_the_halftone() {
|
|
let tree = menus::menu_tree();
|
|
let items = |menu: &str| format!("{:?}", tree.iter().find(|(t, _)| *t == menu).unwrap().1);
|
|
let effect = items("Effect");
|
|
for label in ["Color Adjustments", "Brightness/Contrast…", "Curves…", "Hue/Saturation…", "Levels…", "Shift to Color…", "Temperature/Tint…"]
|
|
{
|
|
assert!(effect.contains(label), "{label}");
|
|
}
|
|
assert!(items("Object").contains("Vector Halftone…"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_adjustment_dialog_has_sliders_and_previews_live() {
|
|
let mut app = app();
|
|
app.run("effect.dialog", json!({"effect": "adjust.hueSaturation"})).unwrap();
|
|
let text = dialog_text(&mut app);
|
|
for s in ["Hue/Saturation", "Hue", "Saturation", "Lightness", "Colorize", "Preview"] {
|
|
assert!(text.lines().any(|l| l == s), "{s} in {text}");
|
|
}
|
|
assert!(app.session.in_interaction(), "the preview runs");
|
|
// A value set from outside (ui.dialog.set) previews too.
|
|
app.ui.dialog.as_mut().unwrap().fields.insert("hue".into(), json!(90));
|
|
dialog_text(&mut app);
|
|
let before = app.session.doc().unwrap().history.undo.len();
|
|
dialogs::confirm(&mut app).unwrap();
|
|
assert!(!app.session.in_interaction());
|
|
assert_eq!(app.session.doc().unwrap().history.undo.len(), before + 1, "one undo step");
|
|
let st = app.session.active().unwrap();
|
|
let n = st.doc.node(st.selection.objects[0]).unwrap();
|
|
assert_eq!(n.appearance.effects[0].id, "adjust.hueSaturation");
|
|
assert_eq!(n.appearance.effects[0].params["hue"], 90.0);
|
|
}
|
|
|
|
#[test]
|
|
fn levels_and_curves_show_their_channel_and_values() {
|
|
let mut app = app();
|
|
app.run("effect.dialog", json!({"effect": "adjust.levels"})).unwrap();
|
|
let text = dialog_text(&mut app);
|
|
for s in ["Input Black", "Output White", "Channel", "RGB", "Gamma"] {
|
|
assert!(text.lines().any(|l| l == s), "{s} in {text}");
|
|
}
|
|
app.ui.dialog = None;
|
|
let _ = app.session.cancel_interaction();
|
|
app.run("effect.dialog", json!({"effect": "adjust.curves"})).unwrap();
|
|
let text = dialog_text(&mut app);
|
|
assert!(text.lines().any(|l| l == "Points") && text.contains("0,0 128,128 255,255"), "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn dragging_on_the_curves_graph_adds_and_moves_a_point() {
|
|
use egui::{Event, PointerButton, Pos2, Rect, vec2};
|
|
let mut app = app();
|
|
app.run("effect.dialog", json!({"effect": "adjust.curves"})).unwrap();
|
|
let ctx = egui::Context::default();
|
|
crate::theme::install_fonts(&ctx);
|
|
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 1000.0)));
|
|
let frame = |app: &mut VectorcraftApp, events: Vec<Event>| {
|
|
let input = egui::RawInput { events, screen_rect, ..Default::default() };
|
|
ctx.run_ui(input, |ui| dialogs::show(app, ui.ctx())).textures_delta.clear();
|
|
};
|
|
for _ in 0..3 {
|
|
frame(&mut app, vec![]);
|
|
}
|
|
let graph =
|
|
ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| w.rect.size() == vec2(200.0, 200.0)).map(|w| w.rect));
|
|
let graph = graph.expect("the curve graph");
|
|
// Press above the identity line a quarter of the way in and drag up: a new point, raised.
|
|
let at = |x: f32, y: f32| Pos2::new(graph.left() + x * 200.0, graph.bottom() - y * 200.0);
|
|
let (from, to) = (at(0.25, 0.35), at(0.25, 0.6));
|
|
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
|
frame(&mut app, vec![Event::PointerMoved(from), button(from, true)]);
|
|
for k in 1..=5 {
|
|
frame(&mut app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 5.0))]);
|
|
}
|
|
frame(&mut app, vec![button(to, false)]);
|
|
let points = app.ui.dialog.as_ref().unwrap().str("points");
|
|
let pts: Vec<(f32, f32)> = vectorcraft_effects::curve_points(Some(&json!(points)));
|
|
assert_eq!(pts.len(), 4, "{points}");
|
|
let (x, y) = pts[1];
|
|
assert!((x - 0.25).abs() < 0.01 && (y - 0.6).abs() < 0.01, "{points}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_halftone_menu_item_opens_a_previewing_dialog() {
|
|
let mut app = app();
|
|
app.run("paint.setFill", json!({"color": "#000000"})).unwrap();
|
|
let id = app.session.active().unwrap().selection.objects[0];
|
|
menus::invoke(&mut app, "object.vectorHalftone", json!({}));
|
|
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::halftone::KIND));
|
|
// Agents open it the same way.
|
|
app.ui.dialog = None;
|
|
app.run("ui.menuDialog", json!({"command": "object.vectorHalftone"})).unwrap();
|
|
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::halftone::KIND));
|
|
let text = dialog_text(&mut app);
|
|
for s in ["Vector Halftone", "Shape", "Circle", "Frequency", "Angle", "Screens", "Mono", "Color", "Clip to Art", "Keep Original"] {
|
|
assert!(text.lines().any(|l| l == s), "{s} in {text}");
|
|
}
|
|
assert!(app.session.in_interaction(), "the preview runs");
|
|
assert!(app.session.active().unwrap().doc.node(id).is_none(), "the preview shows the halftone in place of the art");
|
|
app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("cmyk"));
|
|
assert!(!dialog_text(&mut app).lines().any(|l| l == "Color"), "CMYK screens have their own inks");
|
|
let undo = app.session.doc().unwrap().history.undo.len();
|
|
dialogs::confirm(&mut app).unwrap();
|
|
assert!(!app.session.in_interaction());
|
|
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
|
|
let st = app.session.active().unwrap();
|
|
assert_eq!(st.doc.node(st.selection.objects[0]).unwrap().name.as_deref(), Some("Vector Halftone"));
|
|
}
|