/** * ConnectorGeometry.js * * Draws a closed canvas-2D path for a single puzzle piece. * The piece's grid cell occupies [tabSize, tabSize] to [tabSize+pieceW, tabSize+pieceH] * within an off-screen canvas of size (pieceW+2*tabSize) × (pieceH+2*tabSize). * * Tab/blank bumps occupy the middle third of each internal edge. * tabSize should be >= pieceW * 0.3 to give the tab room to protrude. */ /** * Add a connector bump between two points in canvas space. * @param {CanvasRenderingContext2D} ctx * @param {number} x0,y0 - start of edge segment * @param {number} x1,y1 - end of edge segment * @param {number} dir - +1 = tab (protrudes to left of travel), -1 = blank (indents) * * "Left of travel" in canvas coords: * Travelling right (+X): left = -Y (tab protrudes upward on canvas) * Travelling down (+Y): left = +X * etc. * We want the tab to protrude *outward* from the piece, so callers pass the * edge direction and we compute the perpendicular accordingly. */ function _addConnectorBump(ctx, x0, y0, x1, y1, dir) { // Edge vector const ex = x1 - x0; const ey = y1 - y0; const L = Math.sqrt(ex * ex + ey * ey); // Unit edge and perpendicular (perpendicular points "outward" when dir=+1) const ux = ex / L; const uy = ey / L; // Outward perpendicular = rotate edge 90° clockwise: (uy, -ux) const px = uy * dir; const py = -ux * dir; // Bump parameters as fractions of edge length const H = L * 0.28; // bump height const R = L * 0.10; // knob half-width // Bump occupies centre third: t ∈ [1/3, 2/3] // Points along the edge const bx0 = x0 + ux * (L / 3); const by0 = y0 + uy * (L / 3); const bx1 = x0 + ux * (L * 2 / 3); const by1 = y0 + uy * (L * 2 / 3); const bmx = x0 + ux * (L / 2); // midpoint of edge const bmy = y0 + uy * (L / 2); // Base of knob (at H * 0.55 along perpendicular) const kbx = bmx + px * H * 0.55; const kby = bmy + py * H * 0.55; // Tip of knob (at H along perpendicular) const ktx = bmx + px * H; const kty = bmy + py * H; // Draw: lead-in from bump start → left knob base → knob arc → right knob base → lead-out // Lead-in cubic ctx.bezierCurveTo( bx0 + ux * L * 0.04 + px * H * 0.2, by0 + uy * L * 0.04 + py * H * 0.2, // cp1 kbx - ux * R * 1.2, kby - uy * R * 1.2, // cp2 kbx - ux * R, kby - uy * R // end ); // Knob left arc ctx.bezierCurveTo( ktx - ux * R, kty - uy * R, // cp1 ktx - ux * R, kty - uy * R, // cp2 (same → straight-ish) ktx, kty // end (tip) ); // Knob right arc ctx.bezierCurveTo( ktx + ux * R, kty + uy * R, // cp1 ktx + ux * R, kty + uy * R, // cp2 kbx + ux * R, kby + uy * R // end ); // Lead-out cubic ctx.bezierCurveTo( kbx + ux * R * 1.2, kby + uy * R * 1.2, bx1 - ux * L * 0.04 + px * H * 0.2, by1 - uy * L * 0.04 + py * H * 0.2, bx1, by1 ); } /** * Build the full closed clip path for a single piece. * * @param {CanvasRenderingContext2D} ctx * @param {number} gridCol * @param {number} gridRow * @param {number} cols - total columns in grid * @param {number} rows - total rows in grid * @param {number} pieceW - width of one grid cell in pixels * @param {number} pieceH - height of one grid cell in pixels * @param {{ top, right, bottom, left }} edges - 'flat'|'tab'|'blank' per side * @param {number} tabSize - padding around cell in canvas (= max(pieceW,pieceH)*0.3 or similar) */ function buildPiecePath(ctx, gridCol, gridRow, cols, rows, pieceW, pieceH, edges, tabSize) { const x0 = tabSize; // left edge of cell on canvas const y0 = tabSize; // top edge of cell on canvas const x1 = tabSize + pieceW; // right edge const y1 = tabSize + pieceH; // bottom edge ctx.beginPath(); ctx.moveTo(x0, y0); // TOP edge: left → right if (edges.top === 'flat') { ctx.lineTo(x1, y0); } else { // Tab protrudes upward (−Y), blank indents downward (+Y) // "outward" for top edge = −Y → dir = +1 gives perpendicular toward −Y (correct for tab) ctx.lineTo(x0 + (x1 - x0) / 3, y0); _addConnectorBump(ctx, x0, y0, x1, y0, edges.top === 'tab' ? -1 : +1); ctx.lineTo(x1, y0); } // RIGHT edge: top → bottom if (edges.right === 'flat') { ctx.lineTo(x1, y1); } else { ctx.lineTo(x1, y0 + (y1 - y0) / 3); _addConnectorBump(ctx, x1, y0, x1, y1, edges.right === 'tab' ? +1 : -1); ctx.lineTo(x1, y1); } // BOTTOM edge: right → left if (edges.bottom === 'flat') { ctx.lineTo(x0, y1); } else { ctx.lineTo(x1 - (x1 - x0) / 3, y1); _addConnectorBump(ctx, x1, y1, x0, y1, edges.bottom === 'tab' ? -1 : +1); ctx.lineTo(x0, y1); } // LEFT edge: bottom → top if (edges.left === 'flat') { ctx.lineTo(x0, y0); } else { ctx.lineTo(x0, y1 - (y1 - y0) / 3); _addConnectorBump(ctx, x0, y1, x0, y0, edges.left === 'tab' ? +1 : -1); ctx.lineTo(x0, y0); } ctx.closePath(); }