// @ts-check /** * The shapes of the Shape tool (Andy, 2026-10-14: his teacher draws * rectangles to mark out regions of the screen, so Kokkoja needs explicit * rectangles, and with them the common shapes, behind one button): a * rectangle, an ellipse, a line of several points and an arrow with a * barbed head, in the pen's colour and width. * * A shape is an ordinary stroke of the writer's layer, its points * generated here, so that the eraser, Undo, the protocol, the page file * and Shrink need nothing new, and other programs show it from the file. * Two things about the points: * * - A stroke is drawn as a chain of curves through the midpoints between * its points (stroke_geometry.js), which would round every corner. A * vertex given TWICE keeps the chain straight: the curve from the * midpoint before it to the midpoint between the two copies, which is * the vertex itself, is a straight line, and so is the one after. So * the straight-sided shapes repeat each vertex, and an ellipse, which * wants the curve, does not. * * - Every point carries the pressure at which a stroke has its nominal * width, so that a shape's line is the pen's width throughout. * * The arrow is a FILLED stroke (`Stroke.filled`): its outline, shaft and * head in one closed polygon filled with the colour, like the character * 🡺, which Andy chose; a stroke's own line cannot fill a head. */ import { NOMINAL_PRESSURE } from './pen_input.js'; import { POINT_SIZE } from './page_model.js'; /** The shapes, in the order of the menu. The first is the default. */ export const SHAPES = /** @type {const} */ (['rectangle', 'ellipse', 'line', 'arrow']); /** @typedef {typeof SHAPES[number]} Shape */ /** @type {Record} What the button and the menu call each shape. */ export const SHAPE_NAMES = { rectangle: 'Rectangle', ellipse: 'Ellipse', line: 'Line', arrow: 'Arrow' }; /** The key under which the browser remembers the chosen shape. */ export const SHAPE_KEY = 'kokkoja_shape'; /** * @param {unknown} name * @returns {Shape} The shape of that name, or the default. */ export function shapeNamed(name) { return SHAPES.find((shape) => shape === name) ?? SHAPES[0]; } /** * @param {Shape} shape * @returns {boolean} Whether the shape is drawn vertex by vertex, by taps, * rather than dragged open. */ export function isTapped(shape) { return shape === 'line'; } /** * @param {Shape} shape * @returns {boolean} Whether the shape is a filled polygon. */ export function isFilled(shape) { return shape === 'arrow'; } /** * Points along an ellipse are at most this far apart, in page units * (3 mm): close enough for the curve through them to look round. */ const ELLIPSE_STEP = 30; const LEAST_ELLIPSE_POINTS = 24; const MOST_ELLIPSE_POINTS = 720; /** * An arrow's head: its length as a multiple of the line's width (Andy, * 2026-10-14: proportional to the width, the head of a 0.7 mm arrow, * 2.5 mm, being the proportion that looked right), its half-width as a * share of that length, and how far the barbs reach back behind the * shaft's end as a share of it. The proportions of 🡺. */ const HEAD_LENGTH_WIDTHS = 25 / 7; const HEAD_HALF_WIDTH = 0.6; const BARB_DEPTH = 0.3; /** * The points of a stroke through these vertices with straight sides: * each vertex twice (see the module comment), the first and last once. * @param {[number, number][]} vertices * @returns {number[]} x, y, pressure, ... */ export function straightPoints(vertices) { /** @type {number[]} */ const points = []; vertices.forEach(([x, y], i) => { const copies = i === 0 || i === vertices.length - 1 ? 1 : 2; for (let c = 0; c < copies; c++) points.push(x, y, NOMINAL_PRESSURE); }); return points; } /** * The points one more vertex adds to a line in progress: the vertex * twice, so that the side after it is straight too. * @param {number} x * @param {number} y * @returns {number[]} */ export function vertexPoints(x, y) { return [x, y, NOMINAL_PRESSURE, x, y, NOMINAL_PRESSURE]; } /** * The points of a line still being made: the first vertex once, every * later one twice (vertexPoints), since more may follow. * @param {[number, number][]} vertices * @returns {number[]} */ export function lineInProgressPoints(vertices) { return vertices.flatMap(([x, y], i) => (i === 0 ? [x, y, NOMINAL_PRESSURE] : vertexPoints(x, y))); } /** * @param {number} x0 * @param {number} y0 * @param {number} x1 The opposite corner. * @param {number} y1 * @returns {number[]} */ export function rectanglePoints(x0, y0, x1, y1) { return straightPoints([[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]); } /** * An ellipse inscribed in the rectangle, as points along it, the first * repeated at the end to close it. * @param {number} x0 * @param {number} y0 * @param {number} x1 * @param {number} y1 * @returns {number[]} */ export function ellipsePoints(x0, y0, x1, y1) { const cx = (x0 + x1) / 2; const cy = (y0 + y1) / 2; const rx = Math.abs(x1 - x0) / 2; const ry = Math.abs(y1 - y0) / 2; // Ramanujan's approximation of the circumference. const h = ((rx - ry) / (rx + ry)) ** 2 || 0; const circumference = Math.PI * (rx + ry) * (1 + 3 * h / (10 + Math.sqrt(4 - 3 * h))); const count = Math.max(LEAST_ELLIPSE_POINTS, Math.min(MOST_ELLIPSE_POINTS, Math.ceil(circumference / ELLIPSE_STEP))); /** @type {number[]} */ const points = []; for (let i = 0; i <= count; i++) { const angle = 2 * Math.PI * (i % count) / count; points.push(cx + rx * Math.cos(angle), cy + ry * Math.sin(angle), NOMINAL_PRESSURE); } return points; } /** * An arrow from (x0, y0) to its tip at (x1, y1), as the outline of a * filled polygon: the shaft as wide as the line, the head wider, its * back cut in so that the barbs reach behind the shaft's end. * @param {number} x0 * @param {number} y0 * @param {number} x1 * @param {number} y1 * @param {number} width The line's nominal width, in page units. * @returns {number[]} */ export function arrowPoints(x0, y0, x1, y1, width) { const length = Math.hypot(x1 - x0, y1 - y0); if (length === 0) return []; const ux = (x1 - x0) / length; const uy = (y1 - y0) / length; const nx = -uy; const ny = ux; const head = Math.min(length, HEAD_LENGTH_WIDTHS * width); const half = head * HEAD_HALF_WIDTH; const shaft = width / 2; // Where the shaft meets the head's back, nearer the tip than the barbs. const jx = x1 - ux * head * (1 - BARB_DEPTH); const jy = y1 - uy * head * (1 - BARB_DEPTH); const bx = x1 - ux * head; const by = y1 - uy * head; /** @type {[number, number][]} */ const outline = [ [x0 + nx * shaft, y0 + ny * shaft], [jx + nx * shaft, jy + ny * shaft], [bx + nx * half, by + ny * half], [x1, y1], [bx - nx * half, by - ny * half], [jx - nx * shaft, jy - ny * shaft], [x0 - nx * shaft, y0 - ny * shaft], ]; /** @type {number[]} */ const points = []; for (const [x, y] of outline) points.push(x, y, NOMINAL_PRESSURE); return points; } /** * The points of a shape dragged open from (x0, y0) to (x1, y1). * @param {Shape} shape Not 'line', which is made of taps. * @param {number} x0 * @param {number} y0 * @param {number} x1 * @param {number} y1 * @param {number} width * @returns {number[]} */ export function draggedPoints(shape, x0, y0, x1, y1, width) { switch (shape) { case 'rectangle': return rectanglePoints(x0, y0, x1, y1); case 'ellipse': return ellipsePoints(x0, y0, x1, y1); case 'arrow': return arrowPoints(x0, y0, x1, y1, width); default: return straightPoints([[x0, y0], [x1, y1]]); } } /** * The points of a shape moved or resized by Arrange from the rectangle * around its points to another: every point carried along (a rectangle * stays a rectangle, an ellipse an ellipse, a line keeps its angles' * proportions), except an arrow, which is made afresh from its tail and * tip carried along, so that its head and shaft keep the line's width. * @param {import('./page_model.js').Stroke} stroke * @param {import('./page_model.js').Rectangle} from * @param {import('./page_model.js').Rectangle} to * @returns {number[]} The new points. */ export function movedPoints(stroke, from, to) { const sx = from.width > 0 ? to.width / from.width : 1; const sy = from.height > 0 ? to.height / from.height : 1; /** @param {number} x @param {number} y @returns {[number, number]} */ const carry = (x, y) => [to.left + (x - from.left) * sx, to.top + (y - from.top) * sy]; const p = stroke.points; if (stroke.shape === 'arrow' && p.length === 7 * POINT_SIZE) { // The tail is between the two tail corners (points 0 and 6), the tip point 3. const [tx, ty] = carry((p[0] + p[18]) / 2, (p[1] + p[19]) / 2); const [hx, hy] = carry(p[9], p[10]); return arrowPoints(tx, ty, hx, hy, stroke.width); } const points = []; for (let i = 0; i < p.length; i += POINT_SIZE) { const [x, y] = carry(p[i], p[i + 1]); points.push(Math.round(x * 10) / 10, Math.round(y * 10) / 10, p[i + 2]); } return points; } /** * The vertices a shape's points pass through, each once, for drawing a * preview: the repeated vertices of a straight-sided shape collapse. * @param {number[]} points * @returns {[number, number][]} */ export function verticesOf(points) { /** @type {[number, number][]} */ const vertices = []; for (let i = 0; i < points.length; i += POINT_SIZE) { const last = vertices[vertices.length - 1]; if (last && last[0] === points[i] && last[1] === points[i + 1]) continue; vertices.push([points[i], points[i + 1]]); } return vertices; }