Files
Marcos Paulo 4806209761 feat: whiteboard upgrade (.draw format, zoom bar, layers, SVG export) and vault-only engine exports
Whiteboard: zoom bar and dot grid, layers panel, appear motion, sound cues, focus rings
and AccessKit labels; .draw files with legacy .excalidraw still readable;
notescraft-cli export whiteboard.

Security: export.pdf and file.export_whiteboard_svg accept vault-relative out paths only
(they are reachable by the assistant and the control channel). The CLI places absolute
--out files itself via the new notescraft_engine::render_pdf; the UI export worker renders
and the live session writes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 11:12:33 -03:00

179 lines
6.9 KiB
Rust

//! Per-element roughness, dash and fill behaviour:
//! 0 is clean geometry, 1..=3 scale jitter. Output is deterministic per seed.
use notescraft_whiteboard::rough::fill_polylines;
use notescraft_whiteboard::rough::{Polyline, Style, dash as dash_rough};
use notescraft_whiteboard::{Element, FillStyle, IdGen, StrokeStyle, rough};
fn bits(lines: &[Polyline]) -> Vec<u32> {
lines.iter().flat_map(|line| line.iter().flat_map(|p| [p[0].to_bits(), p[1].to_bits()])).collect()
}
fn rect_with_roughness(roughness: u8, seed: u64) -> Element {
let mut ids = IdGen::new();
let mut rect = Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 60.0);
rect.roughness = roughness;
rect.seed = seed;
rect
}
#[test]
fn roughness_zero_matches_clean_for_every_kind() {
for seed in 1..=5u64 {
let mut ids = IdGen::new();
let mut elements = vec![
Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 60.0),
Element::ellipse(&mut ids, 0.0, 0.0, 100.0, 60.0),
Element::diamond(&mut ids, 0.0, 0.0, 100.0, 60.0),
Element::line(&mut ids, 0.0, 0.0, 100.0, 40.0),
Element::arrow(&mut ids, 0.0, 0.0, 100.0, 0.0),
];
for element in &mut elements {
element.seed = seed;
element.roughness = 0;
}
for element in &elements {
let clean = rough::outline(element, Style::Clean);
let hand = rough::outline(element, Style::HandDrawn);
assert_eq!(bits(&clean), bits(&hand), "roughness 0 should match Clean for seed {seed}");
}
}
}
#[test]
fn roughness_two_differs_from_zero_with_same_seed() {
let zero = rect_with_roughness(0, 42);
let two = rect_with_roughness(2, 42);
let zero_out = rough::outline(&zero, Style::HandDrawn);
let two_out = rough::outline(&two, Style::HandDrawn);
assert_eq!(zero_out.len(), 1, "roughness 0 ⇒ single stroke");
assert_eq!(two_out.len(), 2, "roughness 2 ⇒ double stroke");
assert_ne!(bits(&zero_out), bits(&two_out), "jitter must move the geometry");
}
#[test]
fn same_seed_and_roughness_give_identical_output() {
let a = rect_with_roughness(2, 99);
let b = rect_with_roughness(2, 99);
let first = rough::outline(&a, Style::HandDrawn);
let second = rough::outline(&b, Style::HandDrawn);
let again = rough::outline(&a, Style::HandDrawn);
assert_eq!(bits(&first), bits(&second));
assert_eq!(bits(&first), bits(&again));
}
#[test]
fn higher_roughness_perturbs_more() {
let r1 = rect_with_roughness(1, 7);
let r3 = rect_with_roughness(3, 7);
let one = rough::outline(&r1, Style::HandDrawn);
let three = rough::outline(&r3, Style::HandDrawn);
assert_eq!(one.len(), 2);
assert_eq!(three.len(), 2);
assert_ne!(bits(&one), bits(&three), "higher roughness should perturb the geometry differently");
}
#[test]
fn preview_force_clean_with_style_argument() {
let mut ids = IdGen::new();
let mut rect = Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 60.0);
rect.roughness = 1;
let clean = rough::outline(&rect, Style::Clean);
assert_eq!(clean.len(), 1, "Style::Clean is an explicit clean override");
assert_eq!(clean[0].len(), 5, "4 corners plus the closing point");
}
#[test]
fn solid_stroke_returns_one_polyline() {
let strokes = vec![vec![[0.0, 0.0], [10.0, 0.0], [20.0, 0.0]]];
let dashed = dash_rough(&strokes, StrokeStyle::Solid);
assert_eq!(dashed, strokes);
}
#[test]
fn dashed_long_line_produces_many_segments() {
let mut ids = IdGen::new();
let line = Element::line(&mut ids, 0.0, 0.0, 1000.0, 0.0);
let strokes = rough::outline(&line, Style::Clean);
let dashed = dash_rough(&strokes, StrokeStyle::Dashed);
assert!(dashed.len() >= 2, "a 1000-pt line should dash into >= 2 segments, got {}", dashed.len());
for polyline in &dashed {
assert!(polyline.len() >= 2);
assert!(polyline.iter().all(|p| p[0].is_finite() && p[1].is_finite()));
}
}
#[test]
fn dotted_long_line_produces_many_dots() {
let mut ids = IdGen::new();
let line = Element::line(&mut ids, 0.0, 0.0, 1000.0, 0.0);
let strokes = rough::outline(&line, Style::Clean);
let dotted = dash_rough(&strokes, StrokeStyle::Dotted);
assert!(dotted.len() >= 50, "dotting a 1000-pt line should produce >= 50 dots");
}
#[test]
fn dash_does_not_explode_on_hostile_input() {
let strokes = vec![vec![[0.0, 0.0], [1.0e9, 0.0]]];
let dashed = dash_rough(&strokes, StrokeStyle::Dashed);
assert!(dashed.len() <= 1024, "hostile coordinates must not blow the segment cap: {}", dashed.len());
}
#[test]
fn fill_solid_returns_one_closed_outline() {
let mut ids = IdGen::new();
let rect = Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 60.0);
let lines = fill_polylines(&rect);
assert_eq!(lines.len(), 1);
let polyline = &lines[0];
assert!(polyline.len() >= 4);
assert_eq!(polyline.first(), polyline.last());
}
#[test]
fn fill_hachure_spacing_matches_stroke_width_and_is_deterministic() {
let mut ids = IdGen::new();
let mut rect = Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 60.0);
rect.stroke_width = 4.0;
rect.fill_style = FillStyle::Hachure;
let lines = fill_polylines(&rect);
assert!(lines.len() >= 3, "100x60 box should hatch into >= 3 lines, got {}", lines.len());
for polyline in &lines {
for point in polyline {
assert!(point[0].is_finite() && point[1].is_finite(), "non-finite point {point:?}");
}
}
let again = fill_polylines(&rect);
assert_eq!(bits(&lines), bits(&again), "fill output is bit-identical per element");
rect.stroke_width = 0.5;
let thin = fill_polylines(&rect);
let thick = fill_polylines(&Element { stroke_width: 8.0, ..rect.clone() });
assert!(thin.len() > thick.len(), "thinner stroke packs more lines: {} vs {}", thin.len(), thick.len());
}
#[test]
fn fill_cross_hatch_is_more_lines_than_hachure() {
let mut ids = IdGen::new();
let mut rect = Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 60.0);
rect.fill_style = FillStyle::Hachure;
let hachure_count = fill_polylines(&rect).len();
rect.fill_style = FillStyle::CrossHatch;
let cross_count = fill_polylines(&rect).len();
assert!(cross_count > hachure_count, "cross-hatch must produce more lines than hachure ({cross_count} vs {hachure_count})");
for polyline in fill_polylines(&rect) {
for point in polyline {
assert!(point[0] >= -0.001 && point[0] <= 100.001, "x escaped the box: {point:?}");
assert!(point[1] >= -0.001 && point[1] <= 60.001, "y escaped the box: {point:?}");
}
}
}
#[test]
fn fill_on_unsupported_kind_returns_empty() {
let mut ids = IdGen::new();
let line = Element::line(&mut ids, 0.0, 0.0, 100.0, 0.0);
assert_eq!(fill_polylines(&line), Vec::<Polyline>::new());
let text = Element::text(&mut ids, 0.0, 0.0, "hi");
assert_eq!(fill_polylines(&text), Vec::<Polyline>::new());
}