Skip to content
pokemontcgapi.com
Documentation

Build the line editor: from measure to confirm

Build the human review step with explicit coordinate transforms, corner and line editing, and a tested JavaScript reference implementation.

1. The editor boundary

Start with the photo you uploaded and the JSON response from measure. This guide uses geometry_version: 1. Keep measurement.data, including geometry, face and card_size. Show the photo, corners, cut lines, print lines, ratios, uncertainty and warnings. If geometry is absent, ask for another photo before offering confirmation. needs-review is a normal HTTP 200 result that needs a person to inspect the evidence.

  • Corners wrong: let the person drag four corners, validate them, then call measure again with corners. Replace the old geometry with the new response and start line review again.
  • Corners right: let the person inspect, shift or tilt the eight cut and print lines. Only an explicit acceptance action calls confirm, even if the person accepts every line unchanged.

Keep corner correction and line correction as separate modes. A drag, a successful measure response or an assistant deciding the lines look plausible must never submit confirm. The API requires a person to look at the lines before photo-based ceilings. This is a ceiling from centering alone, not a prediction of the final grade.

2. Two coordinate systems

Engine image pixels to displayed photo

geometry.image.width and height describe the image after EXIF orientation and reduction to at most 2560 px on the longest edge. geometry.corners are TL, TR, BR, BL in those engine image pixels, clockwise with y increasing downward. Orient the displayed photo the same way; use the returned dimensions rather than recomputing the reduction and its rounding.

W = geometry.image.width; H = geometry.image.height
Dw, Dh = displayed EXIF-oriented photo dimensions in CSS pixels
sx = Dw / W; sy = Dh / H
photo point on screen: (left + sx*x, top + sy*y)
pointer back to engine: ((screenX - left)/sx, (screenY - top)/sy)

left and top are the displayed photo origin. For a canvas exactly over an uncropped photo they are zero inside the canvas. With object-fit: contain, use the actual photo rectangle inside the letterbox; with cropping, include the crop translation too. Keep devicePixelRatio separate from this scale: set the canvas backing dimensions to Dw·DPR and Dh·DPR, its CSS dimensions to Dw and Dh, then ctx.setTransform(DPR, 0, 0, DPR, 0, 0). Pointer coordinates and the reference drawOverlay both use CSS pixels.

Rectified pixels, padding and orientation

The card sizes are standard 63.5×88.9 mm (ratio 1.4) and small 59×86 mm (ratio 86/59). The canonical cut width cw is 1000, mx = 50, h = round(1000·ratio), my = round(h·0.05). Before rotation the padded canvas is (1000 + 2·mx) × (h + 2·my), and the target quadrilateral is [(mx,my), (mx+cw,my), (mx+cw,my+h), (mx,my+h)]. These are calibration corners; the detected cut lines may differ slightly.

card_sizehmyCanvas before rotationsTarget TL → BR
standard1400701100×1540(50,70) → (1050,1470)
small1458731100×1604(50,73) → (1050,1531)

Apply rectified.rotations clockwise quarter-turns to each target point: (x,y) → (height − 1 − y, x), then swap width and height after each turn. Use the current height on each iteration, including the −1 pixel convention. Keep each point’s original canonical identity; do not sort the rotated target. Odd rotations swap the canvas dimensions. The reference checks the resulting dimensions against geometry.rectified.

For canonical corner i, the destination is geometry.corners[(i + rectified.corner_offset) % 4]. This association applies before rotations; pair that same destination with canonical target i after rotating it. corner_offset does not rotate the photo or reorder the public corners. rectifiedToImage builds H = homography(rotatedTarget, associatedCorners), the same operations the measuring engine uses.

H = [a b c; d e f; g j k]
den = g*x + j*y + k
imageX = (a*x + b*y + c) / den
imageY = (d*x + e*y + f) / den

vertical left/right:   x = intercept + slope*y; t = y
horizontal top/bottom: y = intercept + slope*x; t = x
at(line,t) = intercept + slope*t

The reference solves a normalized four-point DLT for H with Gaussian elimination. Reject degenerate quadrilaterals and points at infinity. A vertical line uses points (at(line,t),t) over t ∈ [0, rectified.height−1]; a horizontal line uses (t,at(line,t)) over [0, rectified.width−1]. Sample at least two points, project each with H, then apply the display scale. A homography maps a straight line to a straight line; two endpoints suffice for this overlay. linePoints accepts more samples if useful.

3. Draw the overlay

Place a transparent, borderless canvas above the EXIF-oriented photo. Align it to the actual photo bounds and use touch-action: none for dragging. Draw corners in red, cut lines in cyan and print lines in yellow, with distinct endpoint handles. drawOverlay does this without clearing the canvas; clear before each repaint. Keep a legend, ratios and warnings visible beside it. The corner markers belong to the corner correction mode, while the endpoint handles edit lines.

An optional rectified view makes the lines nearly straight and aligned with the axes. inverse(H) maps image points to rectified pixels, including pointer positions from the photo. To rasterize the rectified view without holes, iterate its output pixels, project them through H into the source photo, and sample the photo there. Use the engine image or scale the sampling coordinates to the EXIF-oriented source resolution. Draw and edit lines directly in rectified coordinates in that view.

4. Shift, drag and turn lines

All edit deltas and pivots below use rectified pixels. For a parallel shift, intercept′ = intercept + delta and slope′ = slope. delta moves x for left/right and y for top/bottom; it is an intercept shift, not a perpendicular distance. Map a pointer through inverse(H) before calculating a delta on a photo overlay.

Move endpoint at station t, holding opposite station pivot fixed:
slope′ = slope + delta / (t − pivot)
intercept′ = at(line,pivot) − slope′*pivot
Reject t == pivot. The endpoint moves across the axis at fixed t.

Turn around station pivot by degrees:
slope′ = tan(atan(slope) + degrees*π/180)
intercept′ = at(line,pivot) − slope′*pivot

Intersection of vertical v and horizontal h:
den = 1 − v.slope*h.slope
x = (v.intercept + v.slope*h.intercept) / den
y = at(h,x)
Reject abs(den) < 1e-6.

For turnLine, the angle belongs to the slope parameter: atan(dx/dy) for vertical sides, atan(dy/dx) for horizontal sides. A positive vertical slope turn is not a universal clockwise screen turn. The reference drag uses stations 0 and span−1, holds the opposite endpoint fixed, and discards pointer motion along the line’s independent axis. Cancelling the pointer restores the previous line.

While editing lines, preserve geometry_version, image, corners, rectified (including rotations and corner_offset), card_size, warnings and positioning_sigma unchanged. Keep the measured face and card_size. Send every line with only intercept and slope: the schema is strict, so extra keys such as selected, sigma, support or pivot cause HTTP 400. reviewedGeometry clones the geometry and strips those extra line keys without changing metadata.

Validate edits before acceptance or display the server’s 400 validation details and let the person correct them. For each line, with along = height for vertical or width for horizontal and across the other dimension, require finite values, |slope| ≤ (across−1)/(along−1), and 0 ≤ at(line,t) ≤ across−1 at t = 0 and along−1. The cut intersections in TL, TR, BR, BL order must be finite, convex, have area ≥ 100 px² and lie inside the rectified image.

At the two cut stations (top/bottom intercepts for vertical sides, left/right intercepts for horizontal sides), each print side must be inside its corresponding cut side and opposite print lines must remain at least 2 px apart. Every print intersection must also lie inside all four cut lines. Reject degenerate intersections. The server performs these geometric checks in addition to the JSON schema; reviewedGeometry only sanitizes line keys and does not replace them.

5. Correct corners, then measure again

In corner mode, convert the pointer from CSS pixels to engine image pixels with the display formula. Edit a separate draft of four {x,y} points; do not mutate the geometry still used by the line editor. Keep TL, TR, BR, BL in image order. validateCorners throws on invalid input and returns true otherwise.

  • Use four finite points inside the engine image with the server’s 2 px edge tolerance: −2 ≤ x ≤ width+1 and −2 ≤ y ≤ height+1.
  • The quadrilateral must be convex, with area ≥ max(100, width·height·0.001) px² and every edge at least 10 px long.
  • Every consecutive cross product must be positive (clockwise in image coordinates). Additionally x0+x3 < x1+x2 and y0+y1 < y2+y3; a convex polygon with incorrect corner labels is still invalid.
// correctedCorners is a separate draft in engine image pixels.
validateCorners(correctedCorners, measurement.data.geometry.image);
const form = new FormData();
form.set("image", uploadedPhoto); // Same image bytes as the preceding measure.
form.set("face", measurement.data.face);
form.set("card_size", measurement.data.card_size);
form.set("corners", JSON.stringify(correctedCorners));
// Send form to POST /v1/centering/measure through your application server.
// corners is a JSON string of exactly four {x,y} objects in this multipart body.
// Replace measurement with the response; discard old line edits and review again.

Changing corners changes the homography and the rectified measurement. Old line coordinates belong to the previous frame, so carrying those edits into the new geometry can move them away from the borders. Do not edit corners and lines in the same step or patch corners into an old confirm payload. New corners require another measure and another line review.

6. Confirm explicit acceptance

After the person accepts the displayed lines, POST JSON to confirm with the measured face and card_size, the full reviewed geometry and confirmed_by: "person". confirmBody builds exactly this payload. Keep the API key on your application server; the browser passes the accepted body to that server, which calls the API with X-Api-Key and Content-Type: application/json.

const body = {
  face: measurement.data.face,
  card_size: measurement.data.card_size,
  geometry: reviewedGeometry(editableGeometry),
  confirmed_by: "person"
};
// Submit this body only inside the explicit acceptance handler.

Confirm returns data.lr and data.tb (ratio, major_percent, minor_percent, uncertainty_pp, sigma_pp, worst_station_px and stations_major_percent), confidence, warnings, ceilings and basis: "client-confirmed-lines". It returns no geometry: retain the accepted copy in your app. Show source_status and borderline beside each ceiling. This is a ceiling from centering alone, not a prediction of the final grade.

After confirmation, uncertainty describes repeatable line placement in pixels, not detector confidence or whether the person chose the correct frame. The server computes px = (min(rectified.width,rectified.height)/1.1) / max(1,shortest image corner edge), then sigma = hypot(px·0.5, positioning_sigma ?? px·0.5). It assigns this sigma to the confirmed lines, removes fit spread and sets line support and agreement to 1 before recomputing the measurement. Preserve positioning_sigma and warnings so the calculation retains the engine’s placement context.

For each axis the measurement samples three stations at fractions 0.2, 0.5 and 0.8 between the cross-axis cut intercepts. At station t, a = max(0, at(printLow,t)−at(cutLow,t)), b = max(0, at(cutHigh,t)−at(printHigh,t)), and major = 100·max(a,b)/(a+b), or 50 if a+b = 0. The largest major wins. A local ratio preview is optional and approximate; confirm supplies the result to display, including uncertainty and centering-only ceilings. This is a ceiling from centering alone, not a prediction of the final grade.

7. Common integration errors

  • Drawing rectified line coordinates on the photo without the homography.
  • Ignoring EXIF orientation, or sending original camera pixels as corrected corners after engine reduction.
  • Forgetting display scale, letterbox offsets or the difference between CSS pixels and canvas backing pixels.
  • Ignoring corner_offset or rotations, sorting the rotated target, or dropping the −1 in pixel rotation.
  • Sending extra line keys: the strict schema rejects them with HTTP 400.
  • Calling confirm automatically after measure, after a drag, or without an explicit gesture from the person.
  • Submitting crossed, reversed, incorrectly labelled or concave corners.
  • Changing corners and lines in the same step, or keeping old line edits after a new measure.
  • Treating needs-review as an HTTP error: it is HTTP 200; inspect status and geometry.

Reference implementation

The complete framework-free ES module below is read directly from the downloadable JavaScript file. Import it as a browser module or save it locally. The tests exercise the synthetic measure geometry, rotations and corner offsets, editing invariants, corner validation and the public confirm schema. The file makes no network calls and never submits acceptance itself.

centering-editor.js
// Framework-free ES module. Import from /examples/centering-editor.js.
// Coordinates stay in engine pixels until the last display scaling step.
const sides = ['left', 'right', 'top', 'bottom'];
export const at = (line, t) => line.intercept + line.slope * t;
export const verticalSide = (side) => side === 'left' || side === 'right';

function solve(a, b) {
  const m = a.map((row, i) => [...row, b[i]]), n = b.length;
  for (let k = 0; k < n; k++) {
    let p = k;
    for (let i = k + 1; i < n; i++)
      if (Math.abs(m[i][k]) > Math.abs(m[p][k])) p = i;
    [m[k], m[p]] = [m[p], m[k]];
    const d = m[k][k];
    if (Math.abs(d) < 1e-12) throw new Error('degenerate');
    for (let j = k; j <= n; j++) m[k][j] /= d;
    for (let i = 0; i < n; i++) if (i !== k) {
      const f = m[i][k];
      for (let j = k; j <= n; j++) m[i][j] -= f * m[k][j];
    }
  }
  return m.map((row) => row[n]);
}

function multiply(a, b) {
  const c = new Array(9).fill(0);
  for (let i = 0; i < 3; i++)
    for (let j = 0; j < 3; j++)
      for (let k = 0; k < 3; k++) c[i * 3 + j] += a[i * 3 + k] * b[k * 3 + j];
  return c;
}

export function inverse(h) {
  const [a, b, c, d, e, f, g, i, j] = h;
  const m = [e*j-f*i, c*i-b*j, b*f-c*e, f*g-d*j, a*j-c*g,
    c*d-a*f, d*i-e*g, b*g-a*i, a*e-b*d];
  const det = a * m[0] + b * m[3] + c * m[6];
  if (Math.abs(det) < 1e-12) throw new Error('degenerate');
  return m.map((value) => value / det);
}

export function project(h, p) {
  const d = h[6] * p.x + h[7] * p.y + h[8];
  if (Math.abs(d) < 1e-12) throw new Error('point at infinity');
  return { x: (h[0]*p.x + h[1]*p.y + h[2]) / d,
    y: (h[3]*p.x + h[4]*p.y + h[5]) / d };
}

function normalize(q) {
  const x = q.reduce((sum, p) => sum + p.x, 0) / 4;
  const y = q.reduce((sum, p) => sum + p.y, 0) / 4;
  const f = Math.SQRT2 / (q.reduce((sum, p) => sum + Math.hypot(p.x-x, p.y-y), 0) / 4);
  const t = [f, 0, -f*x, 0, f, -f*y, 0, 0, 1];
  return { q: q.map((p) => project(t, p)), t };
}

// Normalized four-point DLT, with h33 fixed in normalized coordinates.
export function homography(from, to) {
  const a = normalize(from), b = normalize(to), rows = [], rhs = [];
  a.q.forEach((p, i) => {
    const u = b.q[i].x, v = b.q[i].y;
    rows.push([p.x,p.y,1,0,0,0,-u*p.x,-u*p.y],
      [0,0,0,p.x,p.y,1,-v*p.x,-v*p.y]);
    rhs.push(u, v);
  });
  const h = multiply(inverse(b.t), multiply([...solve(rows, rhs), 1], a.t));
  return h.map((value) => value / h[8]);
}

// Returns canonical corner identities after clockwise pixel rotations.
// Do not sort these points back into screen TL/TR/BR/BL order.
export function rectifiedTarget(rectified, card_size) {
  if (!['standard', 'small'].includes(card_size)) throw new Error('card_size');
  const h = Math.round(1000 * (card_size === 'small' ? 86 / 59 : 1.4));
  const my = Math.round(h * 0.05);
  let width = 1100, height = h + 2 * my;
  let q = [{ x: 50, y: my }, { x: 1050, y: my },
    { x: 1050, y: my+h }, { x: 50, y: my+h }];
  const rotations = rectified.rotations ?? 0;
  if (!Number.isInteger(rotations) || rotations < 0 || rotations > 3)
    throw new Error('rotations');
  for (let r = 0; r < rotations; r++) {
    q = q.map((p) => ({ x: height - 1 - p.y, y: p.x }));
    [width, height] = [height, width];
  }
  if (rectified.width !== width || rectified.height !== height)
    throw new Error('rectified dimensions');
  return q;
}

export function rectifiedToImage(geometry) {
  const offset = geometry.rectified.corner_offset ?? 0;
  const corners = geometry.corners.map((_, i) => geometry.corners[(i + offset) % 4]);
  return homography(rectifiedTarget(geometry.rectified, geometry.card_size), corners);
}

// Full padded span, inclusive pixel endpoints: y for vertical, x for horizontal.
export function linePoints(line, side, geometry, samples = 2) {
  if (!sides.includes(side) || !Number.isInteger(samples) || samples < 2)
    throw new Error('side or samples');
  const vertical = verticalSide(side);
  const span = (vertical ? geometry.rectified.height : geometry.rectified.width) - 1;
  const map = rectifiedToImage(geometry);
  return Array.from({ length: samples }, (_, i) => {
    const t = span * i / (samples - 1);
    return project(map, vertical ? { x: at(line, t), y: t } : { x: t, y: at(line, t) });
  });
}

const displayScale = (scale) => typeof scale === 'number' ? { x: scale, y: scale } : scale;

// ctx uses CSS pixels (set its DPR transform separately). scale maps engine
// image pixels to the displayed, EXIF-oriented photo, without crop or letterbox.
// Caller clears the transparent canvas before repainting.
export function drawOverlay(ctx, geometry, scale) {
  const s = displayScale(scale);
  const circle = (p, radius) => {
    ctx.beginPath(); ctx.arc(p.x*s.x, p.y*s.y, radius, 0, 2*Math.PI); ctx.stroke();
  };
  ctx.save();
  ctx.lineWidth = 2;
  for (const [group, color] of [['cut', '#00cfe8'], ['print', '#ffd84d']]) {
    ctx.strokeStyle = color;
    for (const side of sides) {
      const points = linePoints(geometry.lines[group][side], side, geometry, 2);
      ctx.beginPath();
      points.forEach((p, i) => {
        if (i === 0) ctx.moveTo(p.x*s.x, p.y*s.y);
        else ctx.lineTo(p.x*s.x, p.y*s.y);
      });
      ctx.stroke();
      points.forEach((p) => circle(p, 4)); // Endpoint drag handles.
    }
  }
  ctx.strokeStyle = '#ff5169';
  geometry.corners.forEach((p) => circle(p, 6));
  ctx.restore();
}

export function shiftLine(line, delta) {
  return { intercept: line.intercept + delta, slope: line.slope };
}

export function moveEndpoint(line, t, pivot, delta) {
  if (Math.abs(t - pivot) < 1e-12) throw new Error('endpoint equals pivot');
  const slope = line.slope + delta / (t - pivot);
  return { slope, intercept: at(line, pivot) - slope * pivot };
}

// For vertical sides this turns the x(y) slope, not a screen-space angle.
export function turnLine(line, degrees, pivot) {
  const slope = Math.tan(Math.atan(line.slope) + degrees * Math.PI / 180);
  return { slope, intercept: at(line, pivot) - slope * pivot };
}

export function intersection(v, h) {
  const den = 1 - v.slope * h.slope;
  if (Math.abs(den) < 1e-6) throw new Error('degenerate');
  const x = (v.intercept + v.slope * h.intercept) / den;
  return { x, y: at(h, x) };
}

// Preserve all geometry metadata; discard UI/detector keys on each strict line.
export function reviewedGeometry(geometry) {
  const copy = structuredClone(geometry);
  for (const group of ['cut', 'print'])
    for (const side of sides) {
      const { intercept, slope } = copy.lines[group][side];
      copy.lines[group][side] = { intercept, slope };
    }
  return copy;
}

// A payload builder only. Call from the person's explicit acceptance handler.
export function confirmBody(measurement, reviewed) {
  const { face, card_size } = measurement.data;
  if (reviewed.card_size !== card_size) throw new Error('card_size mismatch');
  return { face, card_size, geometry: reviewedGeometry(reviewed), confirmed_by: 'person' };
}

// Throws on invalid input; true on success. Same geometric rules as the server.
export function validateCorners(q, image) {
  if (!Array.isArray(q) || q.length !== 4 || q.some((p) => !p ||
    Object.keys(p).some((key) => key !== 'x' && key !== 'y') ||
    !Number.isFinite(p.x) || !Number.isFinite(p.y) ||
    p.x < -2 || p.y < -2 || p.x > image.width + 1 || p.y > image.height + 1))
    throw new Error('Four {x,y} points within the engine image (2 px edge tolerance) required');
  const crosses = q.map((p, i) => {
    const b = q[(i+1)%4], c = q[(i+2)%4];
    return (b.x-p.x)*(c.y-b.y) - (b.y-p.y)*(c.x-b.x);
  });
  const area = Math.abs(q.reduce((sum, p, i) => {
    const b = q[(i+1)%4];
    return sum + p.x*b.y - p.y*b.x;
  }, 0)) / 2;
  if (!crosses.every((v) => v !== 0 && Math.sign(v) === Math.sign(crosses[0])) ||
    area < Math.max(100, image.width * image.height * 0.001) ||
    q.some((p, i) => Math.hypot(p.x-q[(i+1)%4].x, p.y-q[(i+1)%4].y) < 10))
    throw new Error('Convex quadrilateral, sufficient area and edges of at least 10 px required');
  if (crosses.some((v) => v <= 0) ||
    q[0].x + q[3].x >= q[1].x + q[2].x || q[0].y + q[1].y >= q[2].y + q[3].y)
    throw new Error('Order must be TL, TR, BR, BL clockwise in image coordinates');
  return true;
}

// Minimal endpoint drag hookup on the photo overlay. Mutates only geometry.lines.
// Keep canvas borderless, aligned to the uncropped photo, with touch-action:none.
// onChange repaints; onError displays failures. Rebind after resize/new measure.
export function attachEndpointDrag(canvas, geometry, scale, onChange, onError = () => {}) {
  const s = displayScale(scale), map = inverse(rectifiedToImage(geometry));
  let drag;
  const screen = (event) => {
    const box = canvas.getBoundingClientRect();
    return { x: event.clientX - box.left, y: event.clientY - box.top };
  };
  const rectPoint = (p) => project(map, { x: p.x/s.x, y: p.y/s.y });
  const down = (event) => {
    if (drag || event.button !== 0) return;
    try {
      const p = screen(event), hits = [];
      for (const group of ['cut', 'print']) for (const side of sides) {
        const line = geometry.lines[group][side];
        linePoints(line, side, geometry, 2).forEach((end, index) => {
          const distance = Math.hypot(end.x*s.x-p.x, end.y*s.y-p.y);
          if (distance <= 10) hits.push({ group, side, index, distance });
        });
      }
      const hit = hits.sort((a, b) => a.distance - b.distance)[0];
      if (!hit) return;
      canvas.setPointerCapture(event.pointerId);
      const span = (verticalSide(hit.side) ? geometry.rectified.height : geometry.rectified.width) - 1;
      drag = { ...hit, id: event.pointerId, t: hit.index * span,
        pivot: (1-hit.index) * span, start: rectPoint(p),
        line: { ...geometry.lines[hit.group][hit.side] } };
      event.preventDefault();
    } catch (error) { onError(error); }
  };
  const move = (event) => {
    if (!drag || drag.id !== event.pointerId) return;
    try {
      const p = rectPoint(screen(event));
      // Endpoint stays at its original along-axis station; only across-axis
      // movement changes the slope, keeping the opposite endpoint fixed.
      const delta = verticalSide(drag.side) ? p.x-drag.start.x : p.y-drag.start.y;
      geometry.lines[drag.group][drag.side] = moveEndpoint(drag.line, drag.t, drag.pivot, delta);
      onChange();
    } catch (error) { onError(error); }
  };
  const finish = (event) => {
    if (!drag || drag.id !== event.pointerId) return;
    if (event.type !== 'pointerup') geometry.lines[drag.group][drag.side] = drag.line;
    const id = drag.id;
    drag = undefined;
    if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
    onChange();
  };
  const handlers = { pointerdown: down, pointermove: move, pointerup: finish,
    pointercancel: finish, lostpointercapture: finish };
  for (const [name, handler] of Object.entries(handlers)) canvas.addEventListener(name, handler);
  return () => {
    if (drag) finish({ type: 'pointercancel', pointerId: drag.id });
    for (const [name, handler] of Object.entries(handlers)) canvas.removeEventListener(name, handler);
  };
}

Wire it to your application

Here canvas is a transparent overlay on the loaded, uncropped EXIF-oriented photo; acceptButton is the explicit acceptance control. submitConfirm is your application’s server callback for the API request and showError displays errors. This hookup edits endpoint handles on the photo; shiftLine and turnLine can be bound to separate controls. Recreate the hookup after resizing the photo or receiving new geometry.

import { drawOverlay, attachEndpointDrag, reviewedGeometry, confirmBody }
  from "/examples/centering-editor.js";

export function mountEditor({ canvas, photo, acceptButton, measurement, submitConfirm, showError }) {
  const geometry = reviewedGeometry(measurement.data.geometry);
  const box = photo.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
  const scale = { x: box.width / geometry.image.width, y: box.height / geometry.image.height };
  canvas.style.width = box.width + "px";
  canvas.style.height = box.height + "px";
  canvas.style.touchAction = "none";
  canvas.width = Math.round(box.width * dpr);
  canvas.height = Math.round(box.height * dpr);
  const ctx = canvas.getContext("2d");
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  const repaint = () => {
    ctx.clearRect(0, 0, box.width, box.height);
    drawOverlay(ctx, geometry, scale);
  };
  const detach = attachEndpointDrag(canvas, geometry, scale, repaint, showError);
  const accept = async () => {
    acceptButton.disabled = true;
    try {
      const accepted = reviewedGeometry(geometry); // Retain this copy with the result.
      await submitConfirm(confirmBody(measurement, accepted));
    } catch (error) { showError(error); }
    finally { acceptButton.disabled = false; }
  };
  acceptButton.addEventListener("click", accept);
  repaint();
  return () => { detach(); acceptButton.removeEventListener("click", accept); };
}

8. Note for coding assistants

Acceptance belongs in the application

MCP can measure through ptcg_measure_centering. There is no MCP confirm tool. Build the photo overlay and collect the person’s explicit acceptance in the application, then call confirm there. An assistant must not assert that the person reviewed the lines. This is a ceiling from centering alone, not a prediction of the final grade.

view this page as markdown