/** * Geometric conflict detection for whiteboard elements. * * Computes pairwise overlap, line-through-element intersection, and * canvas-edge clipping from the raw whiteboard JSON, and renders a * concise text summary for inclusion in the system prompt. * * The agent reads bbox coordinates poorly when left to compute * intersections itself; this surfaces the conflicts directly so the * model can act on them instead of inferring them. */ const CANVAS_WIDTH = 1000; const CANVAS_HEIGHT = 563; const OVERLAP_THRESHOLD = 0.3; // intersection / min-area; flag if >= 30% interface BBox { id: string; type: string; label: string; x: number; y: number; w: number; h: number; } interface LineSeg { id: string; label: string; x1: number; y1: number; x2: number; y2: number; } function stripHtml(html: string): string { return html.replace(/<[^>]*>/g, '').trim(); } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes function elementLabel(el: any): string { switch (el.type) { case 'text': { const t = stripHtml(el.content || '').slice(0, 24); return `text "${t}${t.length >= 24 ? '…' : ''}"`; } case 'latex': { const t = String(el.latex || '').slice(0, 24); return `latex "${t}${t.length >= 24 ? '…' : ''}"`; } case 'shape': { const t = el.text?.content ? stripHtml(el.text.content).slice(0, 16) : ''; return t ? `shape "${t}"` : 'shape'; } case 'table': return `table ${el.data?.length || 0}×${el.data?.[0]?.length || 0}`; case 'chart': return `chart[${el.chartType || 'unknown'}]`; case 'code': return `code(${el.language || 'unknown'})`; case 'image': return 'image'; case 'line': { const pts = el.points as string[] | undefined; const arrow = pts?.includes('arrow') ? 'arrow' : 'line'; return arrow; } default: return el.type || 'element'; } } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement function toBBox(el: any): BBox | null { if (el.type === 'line') return null; if (typeof el.left !== 'number' || typeof el.top !== 'number') return null; if (typeof el.width !== 'number' || typeof el.height !== 'number') return null; return { id: el.id || '', type: el.type, label: elementLabel(el), x: el.left, y: el.top, w: el.width, h: el.height, }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTLineElement function toLineSeg(el: any): LineSeg | null { if (el.type !== 'line') return null; const lx = el.left ?? 0; const ly = el.top ?? 0; const sx = el.start?.[0] ?? 0; const sy = el.start?.[1] ?? 0; const ex = el.end?.[0] ?? 0; const ey = el.end?.[1] ?? 0; return { id: el.id || '', label: elementLabel(el), x1: lx + sx, y1: ly + sy, x2: lx + ex, y2: ly + ey, }; } /** * Relative overlap = intersection area / min(area_A, area_B). * 1.0 means one element is fully covered by the other. */ function relativeOverlap(a: BBox, b: BBox): number { const x1 = Math.max(a.x, b.x); const y1 = Math.max(a.y, b.y); const x2 = Math.min(a.x + a.w, b.x + b.w); const y2 = Math.min(a.y + a.h, b.y + b.h); if (x2 <= x1 || y2 <= y1) return 0; const inter = (x2 - x1) * (y2 - y1); const minArea = Math.min(a.w * a.h, b.w * b.h); return minArea > 0 ? inter / minArea : 0; } function pointInRect(px: number, py: number, b: BBox): boolean { return px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h; } /** * Standard CCW segment-segment intersection (proper crossing only). */ function segmentsIntersect( ax1: number, ay1: number, ax2: number, ay2: number, bx1: number, by1: number, bx2: number, by2: number, ): boolean { const ccw = (x1: number, y1: number, x2: number, y2: number, x3: number, y3: number) => (y3 - y1) * (x2 - x1) - (x3 - x1) * (y2 - y1); const d1 = ccw(bx1, by1, bx2, by2, ax1, ay1); const d2 = ccw(bx1, by1, bx2, by2, ax2, ay2); const d3 = ccw(ax1, ay1, ax2, ay2, bx1, by1); const d4 = ccw(ax1, ay1, ax2, ay2, bx2, by2); return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); } function lineCrossesBBox(line: LineSeg, b: BBox): boolean { if (pointInRect(line.x1, line.y1, b) || pointInRect(line.x2, line.y2, b)) return true; const edges: Array<[number, number, number, number]> = [ [b.x, b.y, b.x + b.w, b.y], [b.x + b.w, b.y, b.x + b.w, b.y + b.h], [b.x + b.w, b.y + b.h, b.x, b.y + b.h], [b.x, b.y + b.h, b.x, b.y], ]; for (const [ex1, ey1, ex2, ey2] of edges) { if (segmentsIntersect(line.x1, line.y1, line.x2, line.y2, ex1, ey1, ex2, ey2)) return true; } return false; } function shortId(id: string): string { return id ? `[${id.slice(0, 8)}]` : ''; } /** * Build a text block listing all detected layout conflicts on the * current whiteboard. Returns empty string when there are no conflicts * (so callers can simply concatenate without needing to check). * * Detected conflicts: * - bbox overlap >= 30% of the smaller element's area * - line/arrow path crossing through any non-line element's bbox * - any element extending past the 1000×563 canvas bounds */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants export function buildWhiteboardConflicts(elements: any[]): string { if (!elements || elements.length === 0) return ''; const bboxes: BBox[] = []; const lines: LineSeg[] = []; for (const el of elements) { if (el?.type === 'line') { const seg = toLineSeg(el); if (seg) lines.push(seg); } else { const b = toBBox(el); if (b) bboxes.push(b); } } const conflicts: string[] = []; // Pairwise overlap between bbox elements for (let i = 0; i < bboxes.length; i++) { for (let j = i + 1; j < bboxes.length; j++) { const ratio = relativeOverlap(bboxes[i], bboxes[j]); if (ratio >= OVERLAP_THRESHOLD) { conflicts.push( `OVERLAP: ${bboxes[i].label}${shortId(bboxes[i].id)} and ${bboxes[j].label}${shortId(bboxes[j].id)} share ${Math.round(ratio * 100)}% of the smaller one's area — they sit on top of each other.`, ); } } } // Lines crossing element bboxes for (const line of lines) { for (const b of bboxes) { if (lineCrossesBBox(line, b)) { conflicts.push( `LINE CROSSES: ${line.label}${shortId(line.id)} from (${Math.round(line.x1)},${Math.round(line.y1)}) to (${Math.round(line.x2)},${Math.round(line.y2)}) passes through ${b.label}${shortId(b.id)} — the line is drawn over content.`, ); } } } // Edge clipping for (const b of bboxes) { const out: string[] = []; if (b.x < 0) out.push(`left edge by ${Math.round(-b.x)}px`); if (b.y < 0) out.push(`top edge by ${Math.round(-b.y)}px`); if (b.x + b.w > CANVAS_WIDTH) out.push(`right edge by ${Math.round(b.x + b.w - CANVAS_WIDTH)}px`); if (b.y + b.h > CANVAS_HEIGHT) out.push(`bottom edge by ${Math.round(b.y + b.h - CANVAS_HEIGHT)}px`); if (out.length > 0) { conflicts.push( `OUT OF CANVAS: ${b.label}${shortId(b.id)} extends past ${out.join(', ')} — content is clipped.`, ); } } if (conflicts.length === 0) return ''; const lines_out = conflicts.map((c) => ` - ${c}`).join('\n'); return `\n## ⚠ Layout Conflicts Detected (computed from current whiteboard JSON) The following geometric conflicts exist on the board RIGHT NOW. Each entry is a real visible problem on the current board. You MUST address these before adding new content — either wb_delete one of the conflicting elements, or wb_clear and start fresh: ${lines_out} `; }