ghostyles/brush.jsjavascript
/**
 * ==Ghostyle==
 * @name         Face Brush
 * @slug         brush
 * @version      0.1.0
 * @author       vecna
 * @license      AGPL-3.0-only
 * @release_date 2026-07-04
 * @description  Hold-to-paint brush anchored to face landmarks. Marks track the face and are scored live by the recognition pipeline; bake a promising state into a static Ghostyle.
 * @technique    freehand
 * @supports     2d, params
 * @regions      skin, forehead, cheeks, nose, jaw, brows, eyes, mouth
 * @evidence     experimental
 * ==/Ghostyle==
 */

// ---------------------------------------------------------------------------
// WHY THIS PLUGIN EXISTS
//
// The "aha" moment of Ghostmaxxing is not admiring a pre-made pattern: it is
// drawing something yourself, on your own face, and watching the local
// recognition distance climb in real time until the pipeline stops matching
// you. This plugin turns the cursor into a brush so you can chase that moment
// directly.
//
// HOW IT PLUGS INTO THE EXISTING ENGINE (no engine changes required)
//
//   * We implement only `onDraw(ctx, landmarks)`. The 2D face-api path calls it
//     every render tick on the *visible* overlay, AND `compositeAndDetect()`
//     calls the very same `onDraw` on an offscreen composite that is then
//     re-detected. So whatever we paint is automatically fed back into the
//     matcher. `auto-find-loop.js` already re-runs that composite every ~2s and
//     dispatches `matchStateChanged`, which `bbox-overlay.js` renders as the
//     live `distance` / `obfMinDist` next to the face box. That is the "watch
//     the threshold rise" loop — we don't have to build it.
//
//   * The cursor lives in the overlay's on-screen space; the landmarks handed
//     to `onDraw` live in the overlay's internal pixel space (same space).
//     That coincidence is exactly why a cursor brush belongs in the 2D path
//     rather than the UV/`paintUV` path (which only receives the UV texture and
//     no cursor mapping).
//
// HOW MARKS "TRACK THE FACE"
//
//   Each brush sample is NOT stored as a screen pixel. It is stored relative to
//   the nearest detected landmark, in a similarity frame built from the two eye
//   centres (origin = nearest landmark, axes + scale = inter-ocular vector).
//   Every frame we re-project those samples from the *current* landmarks, so a
//   mark rides the face through translation, in-plane rotation and scale.
//
// PREREQUISITE FOR THE LIVE SCORE
//
//   Save a baseline face first (the app's save/scan button). With no baseline
//   in the local DB there is nothing to measure distance against, so the number
//   won't move even though the paint is applied.
// ---------------------------------------------------------------------------

/** Public API object. Uses the target `window.gstmxx`; falls back defensively. */
const G = (typeof window !== 'undefined' && (window.gstmxx || window.Ghostati)) || {};
function log(msg) {
   if (typeof G.log === 'function') G.log(msg, 'Face Brush');
}

// face-api 68-landmark index groups used to build the stable eye frame.
const LEFT_EYE = [36, 37, 38, 39, 40, 41];
const RIGHT_EYE = [42, 43, 44, 45, 46, 47];

// ------------------------------ module state -------------------------------

/** Committed strokes. Each: { color, alpha, nWidth, samples: [{i,a,b}] }. */
let strokes = [];
/** Stroke currently being drawn, or null. */
let current = null;
/** Whether a pointer is held down and painting. */
let painting = false;

/**
 * Last landmark snapshot seen on the *visible* overlay, used to anchor pointer
 * samples between render ticks. { positions:[{x,y}], frame, t }.
 */
let latest = null;

/** The connected overlay canvas we attached listeners to (guards re-attach). */
let boundCanvas = null;
/** Saved handlers so `onClear` can detach cleanly. */
let handlers = null;
/** The injected floating control panel element. */
let panel = null;

/** Current brush settings (snapshotted per stroke at pointerdown). */
const brush = { color: '#00e5ff', alpha: 0.85, widthPx: 26 };

// ------------------------------ geometry -----------------------------------

function avg(points) {
   let x = 0;
   let y = 0;
   for (const p of points) {
      x += p.x;
      y += p.y;
   }
   return { x: x / points.length, y: y / points.length };
}

/** Build a similarity frame (origin-less): eye centres, unit axes, scale. */
function eyeFrame(positions) {
   const le = avg(LEFT_EYE.map((i) => positions[i]));
   const re = avg(RIGHT_EYE.map((i) => positions[i]));
   const dx = re.x - le.x;
   const dy = re.y - le.y;
   const eyeDist = Math.max(1, Math.hypot(dx, dy));
   const xhat = { x: dx / eyeDist, y: dy / eyeDist };
   const yhat = { x: -xhat.y, y: xhat.x }; // +90°: down the face
   return { le, re, eyeDist, xhat, yhat };
}

function nearestIndex(p, positions) {
   let best = 0;
   let bestD = Infinity;
   for (let i = 0; i < positions.length; i++) {
      const dx = positions[i].x - p.x;
      const dy = positions[i].y - p.y;
      const d = dx * dx + dy * dy;
      if (d < bestD) {
         bestD = d;
         best = i;
      }
   }
   return best;
}

/** Screen point -> face-relative sample {i,a,b}. */
function toLocal(p, positions, frame) {
   const i = nearestIndex(p, positions);
   const anchor = positions[i];
   const dx = p.x - anchor.x;
   const dy = p.y - anchor.y;
   const a = (dx * frame.xhat.x + dy * frame.xhat.y) / frame.eyeDist;
   const b = (dx * frame.yhat.x + dy * frame.yhat.y) / frame.eyeDist;
   return { i, a, b };
}

/** Face-relative sample -> screen point, using the CURRENT landmarks. */
function toScreen(s, positions, frame) {
   const anchor = positions[s.i];
   const ux = frame.xhat.x * s.a + frame.yhat.x * s.b;
   const uy = frame.xhat.y * s.a + frame.yhat.y * s.b;
   return { x: anchor.x + frame.eyeDist * ux, y: anchor.y + frame.eyeDist * uy };
}

// ------------------------------ rendering ----------------------------------

function paintStroke(ctx, stroke, positions, frame) {
   const pts = stroke.samples.map((s) => toScreen(s, positions, frame));
   if (!pts.length) return;
   const width = Math.max(1, stroke.nWidth * frame.eyeDist);

   ctx.save();
   ctx.globalAlpha = stroke.alpha;
   ctx.strokeStyle = stroke.color;
   ctx.fillStyle = stroke.color;
   ctx.lineWidth = width;
   ctx.lineCap = 'round';
   ctx.lineJoin = 'round';

   if (pts.length === 1) {
      ctx.beginPath();
      ctx.arc(pts[0].x, pts[0].y, width / 2, 0, Math.PI * 2);
      ctx.fill();
   } else {
      ctx.beginPath();
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < pts.length - 1; i++) {
         const mx = (pts[i].x + pts[i + 1].x) / 2;
         const my = (pts[i].y + pts[i + 1].y) / 2;
         ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
      }
      const last = pts[pts.length - 1];
      ctx.lineTo(last.x, last.y);
      ctx.stroke();
   }
   ctx.restore();
}

// ------------------------------ pointer I/O --------------------------------

/** True if the canvas carries a horizontal-flip CSS transform (front camera). */
function isMirrored(canvas) {
   const t = getComputedStyle(canvas).transform;
   if (!t || t === 'none') return false;
   const m = t.match(/matrix\(([^)]+)\)/);
   if (!m) return false;
   return parseFloat(m[1].split(',')[0]) < 0; // matrix component 'a'
}

/** Pointer event -> overlay internal coordinates (mirror-aware). */
function eventToCanvas(e, canvas) {
   const rect = canvas.getBoundingClientRect();
   const sx = canvas.width / rect.width;
   const sy = canvas.height / rect.height;
   let x = (e.clientX - rect.left) * sx;
   const y = (e.clientY - rect.top) * sy;
   if (isMirrored(canvas)) x = canvas.width - x;
   return { x, y };
}

function attach(canvas) {
   canvas.style.pointerEvents = 'auto';
   canvas.style.cursor = 'crosshair';
   canvas.style.touchAction = 'none';

   const onDown = (e) => {
      if (!latest) {
         log('No face detected yet — move into frame before painting.');
         return;
      }
      e.preventDefault();
      try { canvas.setPointerCapture(e.pointerId); } catch { /* non-fatal */ }
      painting = true;
      const p = eventToCanvas(e, canvas);
      current = {
         color: brush.color,
         alpha: brush.alpha,
         nWidth: brush.widthPx / latest.frame.eyeDist, // width in eye-distance units
         samples: [toLocal(p, latest.positions, latest.frame)]
      };
   };

   const onMove = (e) => {
      if (!painting || !current || !latest) return;
      e.preventDefault();
      const p = eventToCanvas(e, canvas);
      // Throttle: only keep samples a couple of pixels apart.
      const prev = toScreen(current.samples[current.samples.length - 1], latest.positions, latest.frame);
      if (Math.hypot(p.x - prev.x, p.y - prev.y) < 2) return;
      current.samples.push(toLocal(p, latest.positions, latest.frame));
   };

   const onUp = (e) => {
      if (!painting) return;
      e.preventDefault();
      if (current && current.samples.length) strokes.push(current);
      current = null;
      painting = false;
      updatePanel();
   };

   canvas.addEventListener('pointerdown', onDown);
   canvas.addEventListener('pointermove', onMove);
   canvas.addEventListener('pointerup', onUp);
   canvas.addEventListener('pointercancel', onUp);
   canvas.addEventListener('pointerleave', onUp);

   boundCanvas = canvas;
   handlers = { onDown, onMove, onUp };
}

function detach() {
   if (boundCanvas && handlers) {
      boundCanvas.removeEventListener('pointerdown', handlers.onDown);
      boundCanvas.removeEventListener('pointermove', handlers.onMove);
      boundCanvas.removeEventListener('pointerup', handlers.onUp);
      boundCanvas.removeEventListener('pointercancel', handlers.onUp);
      boundCanvas.removeEventListener('pointerleave', handlers.onUp);
      boundCanvas.style.pointerEvents = 'none'; // restore app default (#overlay)
      boundCanvas.style.cursor = '';
   }
   boundCanvas = null;
   handlers = null;
   painting = false;
   current = null;
}

// ------------------------------ control panel ------------------------------

function buildPanel() {
   if (panel) return;
   panel = document.createElement('div');
   panel.setAttribute('data-gstmxx-plugin', 'brush');
   panel.style.cssText = [
      'position:fixed', 'left:50%', 'bottom:16px', 'transform:translateX(-50%)',
      'z-index:400', 'display:flex', 'flex-wrap:wrap', 'gap:10px', 'align-items:center',
      'padding:10px 14px', 'border-radius:12px', 'pointer-events:auto',
      'background:rgba(15,17,21,0.86)', 'backdrop-filter:blur(8px)',
      'border:1px solid rgba(255,255,255,0.12)', 'color:#eef2ff',
      'font:13px/1.2 Inter,system-ui,sans-serif', 'box-shadow:0 8px 30px rgba(0,0,0,0.45)',
      'max-width:calc(100vw - 24px)'
   ].join(';');

   const label = (text) => {
      const s = document.createElement('span');
      s.textContent = text;
      s.style.opacity = '0.8';
      return s;
   };

   const color = document.createElement('input');
   color.type = 'color';
   color.value = brush.color;
   color.title = 'Brush colour';
   color.style.cssText = 'width:34px;height:26px;border:none;background:none;cursor:pointer';
   color.oninput = () => { brush.color = color.value; };

   const size = document.createElement('input');
   size.type = 'range';
   size.min = '4'; size.max = '90'; size.step = '1'; size.value = String(brush.widthPx);
   size.title = 'Brush size';
   size.oninput = () => { brush.widthPx = parseFloat(size.value); };

   const opacity = document.createElement('input');
   opacity.type = 'range';
   opacity.min = '0.1'; opacity.max = '1'; opacity.step = '0.05'; opacity.value = String(brush.alpha);
   opacity.title = 'Opacity';
   opacity.oninput = () => { brush.alpha = parseFloat(opacity.value); };

   const btn = (text, title, fn) => {
      const b = document.createElement('button');
      b.textContent = text;
      b.title = title;
      b.style.cssText = [
         'padding:6px 10px', 'border-radius:8px', 'cursor:pointer',
         'background:rgba(159,122,234,0.22)', 'border:1px solid rgba(159,122,234,0.5)',
         'color:#fff', 'font:600 12px Inter,system-ui,sans-serif'
      ].join(';');
      b.onclick = fn;
      return b;
   };

   const count = document.createElement('span');
   count.setAttribute('data-role', 'count');
   count.style.cssText = 'opacity:0.7;min-width:74px;text-align:right';

   panel.append(
      label('Brush'), color, label('size'), size, label('opacity'), opacity,
      btn('Undo', 'Remove last stroke', () => { strokes.pop(); updatePanel(); }),
      btn('Clear', 'Remove all strokes', () => { strokes = []; updatePanel(); }),
      btn('Copy state', 'Copy the painted state as JSON', copyState),
      btn('Bake Ghostyle', 'Download this state as a standalone .js Ghostyle', bakeGhostyle),
      count
   );
   document.body.appendChild(panel);
   updatePanel();
}

function updatePanel() {
   if (!panel) return;
   const c = panel.querySelector('[data-role="count"]');
   if (c) c.textContent = `${strokes.length} stroke${strokes.length === 1 ? '' : 's'}`;
}

function removePanel() {
   if (panel && panel.parentNode) panel.parentNode.removeChild(panel);
   panel = null;
}

// ------------------------------ export / bake ------------------------------

function stateJson() {
   return JSON.stringify({ format: 'gstmxx-brush', version: 1, strokes }, null, 2);
}

function copyState() {
   const json = stateJson();
   const done = () => log(`Copied ${strokes.length} stroke(s) to the clipboard as JSON.`);
   if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(json).then(done).catch(() => fallbackCopy(json));
   } else {
      fallbackCopy(json);
   }
}

function fallbackCopy(text) {
   const ta = document.createElement('textarea');
   ta.value = text;
   ta.style.cssText = 'position:fixed;left:-9999px;top:0';
   document.body.appendChild(ta);
   ta.select();
   try { document.execCommand('copy'); log('Copied painted state as JSON.'); }
   catch { log('Could not access clipboard; state logged to console.'); console.log(text); }
   document.body.removeChild(ta);
}

/**
 * Turn the current painted state into a standalone, dependency-free Ghostyle
 * file and download it. This is the honest "author a Ghostyle" path the
 * roadmap describes: paint live, keep what works, bake it into a reusable
 * static plugin. The generated file replays the same eye-frame re-projection.
 */
function bakeGhostyle() {
   if (!strokes.length) {
      log('Nothing to bake yet — paint at least one stroke first.');
      return;
   }
   const ts = new Date();
   const stamp = ts.toISOString().slice(0, 19).replace(/[:T]/g, '-');
   const slug = `brushed-${stamp}`;
   const data = JSON.stringify(strokes);

   const file = `/**
 * ==Ghostyle==
 * @name         Brushed ${ts.toISOString().slice(0, 10)}
 * @slug         ${slug}
 * @version      1.0.0
 * @author       a random Ghostmaxxer
 * @license      AGPL-3.0-only
 * @release_date ${ts.toISOString().slice(0, 10)}
 * @description  Baked freehand pattern captured live with the Face Brush and anchored to face landmarks.
 * @technique    freehand
 * @supports     2d
 * @regions      skin
 * @evidence     experimental
 * ==/Ghostyle==
 */

// Auto-generated by the Face Brush Ghostyle. Each sample is stored relative to
// the nearest 68-landmark point in an inter-ocular similarity frame, so the
// pattern tracks the face. Fully self-contained: no gstmxx helpers required.

const STROKES = ${data};
const LEFT_EYE = [36,37,38,39,40,41];
const RIGHT_EYE = [42,43,44,45,46,47];

function avg(pts){let x=0,y=0;for(const p of pts){x+=p.x;y+=p.y;}return {x:x/pts.length,y:y/pts.length};}
function frameOf(pos){
  const le=avg(LEFT_EYE.map(i=>pos[i])), re=avg(RIGHT_EYE.map(i=>pos[i]));
  const dx=re.x-le.x, dy=re.y-le.y, eyeDist=Math.max(1,Math.hypot(dx,dy));
  const xhat={x:dx/eyeDist,y:dy/eyeDist}; const yhat={x:-xhat.y,y:xhat.x};
  return {eyeDist,xhat,yhat};
}
function toScreen(s,pos,f){
  const a=pos[s.i]; const ux=f.xhat.x*s.a+f.yhat.x*s.b, uy=f.xhat.y*s.a+f.yhat.y*s.b;
  return {x:a.x+f.eyeDist*ux, y:a.y+f.eyeDist*uy};
}

export function onDraw(ctx, landmarks){
  const pos = landmarks.positions.map(p=>({x:p.x,y:p.y}));
  const f = frameOf(pos);
  for(const st of STROKES){
    const pts = st.samples.map(s=>toScreen(s,pos,f));
    if(!pts.length) continue;
    const w = Math.max(1, st.nWidth*f.eyeDist);
    ctx.save();
    ctx.globalAlpha=st.alpha; ctx.strokeStyle=st.color; ctx.fillStyle=st.color;
    ctx.lineWidth=w; ctx.lineCap='round'; ctx.lineJoin='round';
    if(pts.length===1){ctx.beginPath();ctx.arc(pts[0].x,pts[0].y,w/2,0,Math.PI*2);ctx.fill();}
    else{
      ctx.beginPath(); ctx.moveTo(pts[0].x,pts[0].y);
      for(let i=1;i<pts.length-1;i++){const mx=(pts[i].x+pts[i+1].x)/2,my=(pts[i].y+pts[i+1].y)/2;ctx.quadraticCurveTo(pts[i].x,pts[i].y,mx,my);}
      const last=pts[pts.length-1]; ctx.lineTo(last.x,last.y); ctx.stroke();
    }
    ctx.restore();
  }
}
`;

   const blob = new Blob([file], { type: 'text/javascript' });
   const url = URL.createObjectURL(blob);
   const a = document.createElement('a');
   a.href = url;
   a.download = `${slug}.js`;
   document.body.appendChild(a);
   a.click();
   document.body.removeChild(a);
   setTimeout(() => URL.revokeObjectURL(url), 1000);
   log(`Baked ${strokes.length} stroke(s) into ${slug}.js — drop it in ghostyles/ and add it to ghostyles.json.`);
}

// ------------------------------ plugin hooks -------------------------------

export function onInit() {
   return 'Face Brush ready — save a baseline, then hold and paint to watch the distance climb.';
}

/**
 * Called every render tick on the visible overlay, and also on the offscreen
 * composite that gets re-detected. We (a) cache the latest live landmarks so
 * pointer samples can be anchored between ticks, (b) lazily wire up input +
 * the control panel the first time we see the real (connected) overlay, and
 * (c) redraw every stroke from the current landmarks.
 */
export function onDraw(ctx, landmarks) {
   if (!landmarks || !landmarks.positions) return;
   const positions = landmarks.positions.map((p) => ({ x: p.x, y: p.y }));
   const frame = eyeFrame(positions);
   const connected = ctx.canvas.isConnected; // visible overlay vs offscreen composite

   if (connected) {
      latest = { positions, frame, t: Date.now() };
      if (boundCanvas !== ctx.canvas) {
         if (boundCanvas) detach();
         attach(ctx.canvas);
         buildPanel();
      }
   }

   for (const st of strokes) paintStroke(ctx, st, positions, frame);
   if (current) paintStroke(ctx, current, positions, frame);
}

/** Called when the effect is switched off. Tear down input + panel; keep strokes. */
export function onClear() {
   detach();
   removePanel();
   latest = null;
}