diff --git a/riddle/src/ink.rs b/riddle/src/ink.rs index 1b9a549..3935dcb 100644 --- a/riddle/src/ink.rs +++ b/riddle/src/ink.rs @@ -69,6 +69,11 @@ impl Ink { /// Remove committed stroke points within `r` of (x, y); split strokes that /// are erased through the middle, and recompute the ink bbox. + /// + /// Adjacency is also broken when the SEGMENT between two surviving points + /// passes through the eraser: fast strokes store sparse points, so an + /// eraser can whiten the brushed line between two points without touching + /// either — replaying that line would resurrect visually erased ink. fn forget_near(&mut self, x: i32, y: i32, r: i32) { let r2 = (r + 2) * (r + 2); let mut kept: Vec> = Vec::new(); @@ -81,6 +86,11 @@ impl Ink { kept.push(std::mem::take(&mut seg)); } } else { + if let Some(&prev) = seg.last() { + if segment_near(prev.0, prev.1, p.0, p.1, x, y, r + 2) { + kept.push(std::mem::take(&mut seg)); + } + } seg.push(p); } } @@ -108,6 +118,13 @@ impl Ink { /// Crops to the ink bounding box and box-downscales so the long side stays /// ≤ 800px (at least 2x): the model reads handwriting fine at that scale, /// and image pixels are the dominant vision-token / latency cost. + /// + /// The image is built by replaying the stroke model into a clean offscreen + /// buffer — NOT by reading the screen — so anything else on the page (a + /// lingering reply the writer answers underneath) never leaks into what + /// the oracle sees. Erases already edit the stroke model (`forget_near`), + /// so the replay is faithful to the visible ink. `surf` is only consulted + /// for the page dimensions. pub fn to_png(&self, surf: &Surface, path: &str) -> std::io::Result<()> { if self.bbox.is_empty() { return Err(std::io::Error::other("no ink")); @@ -117,8 +134,16 @@ impl Ink { let y0 = (by - 20).max(0) as usize; let x1 = ((bx + bw + 20) as usize).min(surf.w); let y1 = ((by + bh + 20) as usize).min(surf.h); - let f = ((x1 - x0).max(y1 - y0)).div_ceil(800).max(2); - let (w, h) = ((x1 - x0) / f, (y1 - y0) / f); + let (cw, ch) = (x1 - x0, y1 - y0); + + // Full-resolution replay of the writer's ink on a white page crop. + let mut page = vec![255u8; cw * ch]; + for stroke in self.strokes.iter().chain(std::iter::once(&self.current)) { + replay_stroke(&mut page, cw, ch, x0 as i32, y0 as i32, stroke); + } + + let f = cw.max(ch).div_ceil(800).max(2); + let (w, h) = (cw / f, ch / f); let mut gray = vec![0u8; w * h]; for oy in 0..h { @@ -126,7 +151,7 @@ impl Ink { let mut acc = 0u32; for sy in 0..f { for sx in 0..f { - acc += surf.luma((x0 + ox * f + sx) as i32, (y0 + oy * f + sy) as i32) as u32; + acc += page[(oy * f + sy) * cw + ox * f + sx] as u32; } } gray[oy * w + ox] = (acc / (f * f) as u32) as u8; @@ -147,6 +172,57 @@ impl Ink { } } +/// Replay one stroke into a grayscale crop buffer, mirroring the on-screen +/// geometry of `pen_point`: first point stamps a disc; later points brush +/// from the previous one with the same radius-growth clamp. +fn replay_stroke(page: &mut [u8], w: usize, h: usize, ox: i32, oy: i32, pts: &[(i32, i32, i32)]) { + let mut last: Option<(i32, i32, i32)> = None; + for &(x, y, r) in pts { + let (cx, cy) = (x - ox, y - oy); + match last { + Some((px, py, pr)) => brush_g(page, w, h, px, py, cx, cy, r.min(pr + 1)), + None => stamp_g(page, w, h, cx, cy, r), + } + last = Some((cx, cy, r)); + } +} + +/// `Surface::stamp` (black ink) for a grayscale buffer. +fn stamp_g(page: &mut [u8], w: usize, h: usize, cx: i32, cy: i32, r: i32) { + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy <= r * r { + let (x, y) = (cx + dx, cy + dy); + if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h { + page[y as usize * w + x as usize] = 0; + } + } + } + } +} + +/// `Surface::brush_line` (black ink) for a grayscale buffer. +fn brush_g(page: &mut [u8], w: usize, h: usize, x0: i32, y0: i32, x1: i32, y1: i32, r: i32) { + let dx = (x1 - x0).abs(); + let dy = (y1 - y0).abs(); + let steps = dx.max(dy).max(1); + for i in 0..=steps { + let x = x0 + (x1 - x0) * i / steps; + let y = y0 + (y1 - y0) * i / steps; + stamp_g(page, w, h, x, y, r); + } +} + +/// Does the segment A->B pass within `r` of point (x, y)? +fn segment_near(ax: i32, ay: i32, bx: i32, by: i32, x: i32, y: i32, r: i32) -> bool { + let (abx, aby) = ((bx - ax) as f32, (by - ay) as f32); + let (apx, apy) = ((x - ax) as f32, (y - ay) as f32); + let len2 = abx * abx + aby * aby; + let t = if len2 <= f32::EPSILON { 0.0 } else { ((apx * abx + apy * aby) / len2).clamp(0.0, 1.0) }; + let (cx, cy) = (apx - t * abx, apy - t * aby); + cx * cx + cy * cy <= (r * r) as f32 +} + /// Deterministic per-pixel hash for the dissolve pattern. #[inline] fn px_hash(x: i32, y: i32) -> u32 { @@ -208,6 +284,63 @@ mod tests { } } + #[test] + fn to_png_sees_only_the_stroke_model_not_the_screen() { + let (_buf, mut s) = surf(); + let mut ink = Ink::new(); + // The writer's ink: a short stroke. + for x in (60..=160).step_by(10) { + ink.pen_point(&mut s, x, 100, 3); + } + ink.pen_up(); + // A "lingering reply" painted on the SCREEN near the ink (inside the + // crop, clear of the stroke), but not in the stroke model — it must + // not appear in the oracle snapshot. + s.stamp(110, 115, 8, BLACK); + + let path = std::env::temp_dir().join("riddle-ink-test.png"); + ink.to_png(&s, path.to_str().unwrap()).unwrap(); + + let dec = png::Decoder::new(std::fs::File::open(&path).unwrap()); + let mut reader = dec.read_info().unwrap(); + let mut img = vec![0u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut img).unwrap(); + let (w, h) = (info.width as usize, info.height as usize); + let f = 2; // crop is small, so the min downscale factor applies + // Screen-blot center in image coords: crop starts at bbox-20. + let (bx, by, _, _) = ink.bbox.rect(); + let (ix, iy) = ((110 - (bx - 20)) as usize / f, (115 - (by - 20)) as usize / f); + assert!(iy < h && ix < w, "blot should fall inside the crop"); + assert!( + img[iy * w + ix] > 200, + "screen-only pixels leaked into the oracle snapshot (luma {})", + img[iy * w + ix] + ); + // And the real stroke IS there. + let (sx, sy) = ((110 - (bx - 20)) as usize / f, (100 - (by - 20)) as usize / f); + assert!(img[sy * w + sx] < 60, "the writer's ink is missing from the snapshot"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn erasing_between_sparse_points_splits_the_stroke() { + let (_buf, mut s) = surf(); + let mut ink = Ink::new(); + // A sparse stroke: two points 80px apart (a fast pen). + ink.pen_point(&mut s, 60, 100, 3); + ink.pen_point(&mut s, 140, 100, 3); + ink.pen_up(); + assert_eq!(ink.stroke_list().len(), 1); + // Erase midway: neither point center is inside the eraser, but the + // brushed line between them is. + ink.erase_point(&mut s, 100, 100, 15); + assert_eq!( + ink.stroke_list().len(), + 2, + "segment through the eraser must break adjacency" + ); + } + #[test] fn erasing_everything_empties_the_ink() { let (_buf, mut s) = surf(); diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 24601b3..f2708eb 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -60,7 +60,9 @@ type OracleRx = mpsc::Receiver>; enum State { Listening { last_pen: Option }, - Drinking { stage: u32, next: Instant, region: BBox, rx: OracleRx }, + /// `reply`: a lingering reply the writer wrote underneath — drunk + /// together with the new ink (empty when there was none). + Drinking { stage: u32, next: Instant, region: BBox, reply: BBox, rx: OracleRx }, Thinking { rx: OracleRx, pulse: Instant, blot_on: bool, since: Instant }, Replying { plan: WritePlan, next: Instant, rx: Option }, Lingering { until: Instant, region: BBox }, @@ -254,6 +256,9 @@ fn run() -> std::io::Result<()> { let mut stylus_on = false; let mut stylus_tapped = false; let mut ink_dirty = BBox::empty(); + // A reply the writer started answering while it still lingered on the + // page: it stays visible while they write and is drunk with their ink. + let mut pending_reply = BBox::empty(); let mut last_flush = Instant::now(); // Takeover swaps are cheap and synchronous; qtfb needs coalescing. let flush_every = if takeover { Duration::from_millis(8) } else { Duration::from_millis(35) }; @@ -337,6 +342,16 @@ fn run() -> std::io::Result<()> { } continue; } + // Pen contact while a reply lingers: keep Tom's words on the + // page and start inking with this same contact — the reply is + // remembered and drunk together with the new ink at commit. + if let State::Lingering { region, .. } = state { + if !region.is_empty() { + pending_reply.add(region.x0, region.y0, 0); + pending_reply.add(region.x1, region.y1, 0); + } + state = State::Listening { last_pen: None }; + } match state { State::Listening { ref mut last_pen } => { pen_down = true; @@ -353,9 +368,6 @@ fn run() -> std::io::Result<()> { } *last_pen = Some(Instant::now()); } - State::Lingering { region, .. } => { - state = State::FadingReply { stage: 0, next: Instant::now(), region }; - } _ => {} } } @@ -374,6 +386,15 @@ fn run() -> std::io::Result<()> { qtfb::INPUT_PEN_PRESS | qtfb::INPUT_PEN_UPDATE => { stylus_on = true; stylus_tapped = true; + // Same as the raw-pen path: writing over a lingering + // reply keeps it on the page. + if let State::Lingering { region, .. } = state { + if !region.is_empty() { + pending_reply.add(region.x0, region.y0, 0); + pending_reply.add(region.x1, region.y1, 0); + } + state = State::Listening { last_pen: None }; + } if let State::Listening { ref mut last_pen } = state { pen_down = true; let r = 2 + ev.d.clamp(0, 100) / 45; @@ -383,8 +404,6 @@ fn run() -> std::io::Result<()> { ink_dirty.add(d.x1, d.y1, 0); } *last_pen = Some(Instant::now()); - } else if let State::Lingering { region, .. } = state { - state = State::FadingReply { stage: 0, next: Instant::now(), region }; } } qtfb::INPUT_PEN_RELEASE => { @@ -412,12 +431,33 @@ fn run() -> std::io::Result<()> { // ---- state machine ---- state = match state { State::Listening { last_pen } => match last_pen { + // The touch that kept a lingering reply led to no ink at all + // (an eraser tap, or contact without a stroke): give the + // reply its fade back, or it would sit on the page forever. + Some(t) + if !pen_down + && t.elapsed() >= IDLE_COMMIT + && user_ink.is_empty() + && !pending_reply.is_empty() => + { + let region = pending_reply; + pending_reply = BBox::empty(); + State::FadingReply { stage: 0, next: Instant::now(), region } + } Some(t) if !pen_down && t.elapsed() >= IDLE_COMMIT && !user_ink.is_empty() => { if region_all_white(&surf, user_ink.bbox) { // Everything was erased before the pause: nothing to // commit (and no phantom "?" from erased strokes). user_ink.clear(); - State::Listening { last_pen: None } + if pending_reply.is_empty() { + State::Listening { last_pen: None } + } else { + // …but the lingering reply the writer touched + // still needs its fade. + let region = pending_reply; + pending_reply = BBox::empty(); + State::FadingReply { stage: 0, next: Instant::now(), region } + } } else if help::looks_like_question_mark(user_ink.stroke_list()) { // Absorb the "?" and open the guide instead of asking. let (qx, qy, qw, qh) = user_ink.bbox.rect(); @@ -461,26 +501,35 @@ fn run() -> std::io::Result<()> { let _ = std::fs::remove_file(PNG_PATH); } let region = user_ink.bbox; - State::Drinking { stage: 0, next: Instant::now(), region, rx } + let reply = pending_reply; + pending_reply = BBox::empty(); + State::Drinking { stage: 0, next: Instant::now(), region, reply, rx } } } _ => State::Listening { last_pen }, }, - State::Drinking { stage, next, region, rx } => { + State::Drinking { stage, next, region, reply, rx } => { const STAGES: u32 = 14; if Instant::now() >= next { ink::dissolve_pass(&mut surf, region, stage, STAGES); let (x, y, w, h) = region.rect(); disp.update(x, y, w, h, true); + // A reply the writer answered underneath dissolves along + // with their ink. + if !reply.is_empty() { + ink::dissolve_pass(&mut surf, reply, stage, STAGES); + let (x, y, w, h) = reply.rect(); + disp.update(x, y, w, h, true); + } if stage + 1 >= STAGES { user_ink.clear(); State::Thinking { rx, pulse: Instant::now(), blot_on: false, since: Instant::now() } } else { - State::Drinking { stage: stage + 1, next: Instant::now() + Duration::from_millis(70), region, rx } + State::Drinking { stage: stage + 1, next: Instant::now() + Duration::from_millis(70), region, reply, rx } } } else { - State::Drinking { stage, next, region, rx } + State::Drinking { stage, next, region, reply, rx } } }