Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 136 additions & 3 deletions riddle/src/ink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<(i32, i32, i32)>> = Vec::new();
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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"));
Expand All @@ -117,16 +134,24 @@ 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);
Comment on lines +141 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't replay erased stroke segments into snapshots

For fast/sparse strokes, an eraser can whiten the brushed segment between two stored points while forget_near keeps both endpoints because neither point center is within the eraser radius. Replaying the whole stroke model here (instead of sampling surf) redraws that visually erased segment into /tmp/riddle-page.png, so the oracle may read text the user erased; either preserve erased gaps in the model or continue masking/referencing the surface for erasures.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7bb6e3c. forget_near now also breaks adjacency when the segment between two surviving points passes within the eraser radius (point-to-segment distance check), so the replay can't redraw a whitened line between sparse points. Regression test added (erasing_between_sparse_points_splits_the_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 {
for ox in 0..w {
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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
71 changes: 60 additions & 11 deletions riddle/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ type OracleRx = mpsc::Receiver<Result<Event, String>>;

enum State {
Listening { last_pen: Option<Instant> },
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<OracleRx> },
Lingering { until: Instant, region: BBox },
Expand Down Expand Up @@ -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) };
Expand Down Expand Up @@ -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 };
Comment on lines +348 to +353

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't drop lingering state before there is ink to commit

When the first contact on a lingering reply is the eraser, or when the user starts a stroke and then erases/cancels all of it, this transition leaves Lingering and only stashes pending_reply; because pending_reply is cleared only in the later oracle commit path, user_ink.is_empty() prevents that path from ever running and Tom's old reply no longer has a fade timer. That leaves stale reply pixels on the page until an unrelated future turn happens to drink them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7bb6e3c. Both no-ink paths now hand the reply to FadingReply after the idle timeout: contact-without-a-stroke (the new first match arm) and stroke-fully-erased (the region_all_white branch now checks pending_reply before returning to Listening).

}
match state {
State::Listening { ref mut last_pen } => {
pen_down = true;
Expand All @@ -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 };
}
_ => {}
}
}
Expand All @@ -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;
Expand All @@ -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 => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 }
}
}

Expand Down