Files

291 lines
10 KiB
JavaScript
Raw Permalink Normal View History

// @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', 'circle', 'ellipse', 'line', 'arrow']);
/** @typedef {typeof SHAPES[number]} Shape */
/** @type {Record<Shape, string>} What the button and the menu call each shape. */
export const SHAPE_NAMES = { rectangle: 'Rectangle', circle: 'Circle', 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)));
// A multiple of four, so that a point lies at each end of both axes and
// the rectangle around the points is exactly the ellipse's (Arrange's
// box and a circle's proportions depend on it).
const count = Math.max(LEAST_ELLIPSE_POINTS, Math.min(MOST_ELLIPSE_POINTS, 4 * Math.ceil(circumference / ELLIPSE_STEP / 4)));
/** @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;
}
/**
* A circle about its centre (cx, cy), the radius reaching the dragged
* point (Andy, 2026-10-14: the centre first, then the radius in any
* direction).
* @param {number} cx
* @param {number} cy
* @param {number} x
* @param {number} y
* @returns {number[]}
*/
export function circlePoints(cx, cy, x, y) {
const radius = Math.hypot(x - cx, y - cy);
return ellipsePoints(cx - radius, cy - radius, cx + radius, cy + radius);
}
/**
* 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): a
* circle from its centre to a point of its edge, the others from one
* corner of their rectangle to the opposite one.
* @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 'circle': return circlePoints(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; a circle is resized with its shape kept, so stays one),
* 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;
}