/** * Scaling, turning and mirroring vector art shapes (#291). A transformation is baked into the points: * the model keeps no matrix, so everything that reads points – combining, the picture a sprite wears, * a press in the tab – reads them as they are. This module says what a shape reads after an * affine map, how far a selection reaches, and which map a pull on the frame's handles means. * * The outline's width is left alone: a line of two pixels is two pixels at every size of its shape. */ import type { VectorGradient, VectorPaint, VectorPoint, VectorRing, VectorShape } from './Asset'; import { apply, applyVector, type Mat } from './matrix'; import type { Vec2 } from './Properties'; import { cloneVectorPoint, handleAt } from './vectorart'; import { cloneGradient, clonePaint, type Extent } from './vectorPaint'; /** A frame is never pulled thinner than this many canvas pixels, so it can be pulled open again. */ export const MIN_FRAME_SIZE = 1; /** A handle shorter than this is none: the point is a plain corner on that side. */ const NO_HANDLE = 1e-6; /** What of a shape a transformation moves: its outlines and where its paints lie. */ export interface ShapeGeometry { rings: VectorRing[]; paint?: VectorPaint; strokePaint?: VectorGradient; } /** The side of a frame a handle sits on: -1, 0 or 1 on each axis, a corner has both. */ export interface FrameSide { sx: -1 | 0 | 1; sy: -1 | 0 | 1; } /** * Canvas pixels are kept to a thousandth. A map multiplies every coordinate by sines and ratios, and * without this a square turned by 30 degrees and back would read 9.999999999999998 in the file. */ function tidy(value: number): number { const rounded = Math.round(value * 1000) / 1000; return rounded === 0 ? 0 : rounded; } function tidyPoint(p: Vec2): Vec2 { return { x: tidy(p.x), y: tidy(p.y) }; } /** Scales by `fx` and `fy` with `anchor` staying where it is; a negative factor mirrors. */ export function scaleAbout(anchor: Vec2, fx: number, fy: number): Mat { return { a: fx, b: 0, c: 0, d: fy, tx: anchor.x * (1 - fx), ty: anchor.y * (1 - fy) }; } /** * Turns by `degrees` round `centre`, clockwise on screen. The cosine of a quarter turn comes out as * 6e-17 rather than 0; `tidy` takes that off every coordinate, so a square turned four times is * the square it was. */ export function rotationAbout(centre: Vec2, degrees: number): Mat { const r = (degrees * Math.PI) / 180; const cos = Math.cos(r); const sin = Math.sin(r); return { a: cos, b: sin, c: -sin, d: cos, tx: centre.x - cos * centre.x + sin * centre.y, ty: centre.y - sin * centre.x - cos * centre.y, }; } export function extentCentre(extent: Extent): Vec2 { return { x: (extent.x0 + extent.x1) / 2, y: (extent.y0 + extent.y1) / 2 }; } /** The four corners of an extent taken through `m`: top left, top right, bottom right, bottom left. */ export function extentCorners(extent: Extent, m: Mat): Vec2[] { return [ apply(m, { x: extent.x0, y: extent.y0 }), apply(m, { x: extent.x1, y: extent.y0 }), apply(m, { x: extent.x1, y: extent.y1 }), apply(m, { x: extent.x0, y: extent.y1 }), ]; } /** * A ring under `m`. A handle is an offset from its point, so it takes the linear part only. A smooth * or symmetric point stays one: a linear map keeps two opposite offsets opposite. */ export function ringTransformed(ring: VectorRing, m: Mat): VectorRing { return { closed: ring.closed, points: ring.points.map((point) => { const next: VectorPoint = { ...cloneVectorPoint(point), ...tidyPoint(apply(m, point)) }; for (const side of ['in', 'out'] as const) { const handle = point[side]; if (!handle) continue; const moved = tidyPoint(applyVector(m, handle)); if (Math.hypot(moved.x, moved.y) < NO_HANDLE) delete next[side]; else next[side] = moved; } if (!next.in && !next.out) delete next.mode; return next; }), }; } /** * A paint under `m`. The two ends of a gradient go along, which is exact for a move, a turn and an * even scale; a gradient squeezed in one direction keeps its bands square to its axis. A picture * fill is a tile with upright sides: scaled or mirrored it follows exactly, turned it keeps upright * and takes the room the turned tile reaches over – the picture itself does not turn. */ export function paintTransformed

(paint: P, m: Mat): P { const next = clonePaint(paint); if (paint.type !== 'texture' || (m.b === 0 && m.c === 0)) { next.from = tidyPoint(apply(m, paint.from)); next.to = tidyPoint(apply(m, paint.to)); return next; } const centre = apply(m, { x: (paint.from.x + paint.to.x) / 2, y: (paint.from.y + paint.to.y) / 2 }); const hw = (paint.to.x - paint.from.x) / 2; const hh = (paint.to.y - paint.from.y) / 2; const halfWidth = (Math.abs(m.a * hw) + Math.abs(m.c * hh)) * (hw < 0 ? -1 : 1); const halfHeight = (Math.abs(m.b * hw) + Math.abs(m.d * hh)) * (hh < 0 ? -1 : 1); next.from = tidyPoint({ x: centre.x - halfWidth, y: centre.y - halfHeight }); next.to = tidyPoint({ x: centre.x + halfWidth, y: centre.y + halfHeight }); return next; } /** The rings are handed out as they are, not copied: nobody edits a ring in place. */ export function geometryOf(shape: VectorShape): ShapeGeometry { return { rings: shape.rings, ...(shape.paint ? { paint: clonePaint(shape.paint) } : {}), ...(shape.stroke?.paint ? { strokePaint: cloneGradient(shape.stroke.paint) } : {}), }; } export function geometryTransformed(geometry: ShapeGeometry, m: Mat): ShapeGeometry { return { rings: geometry.rings.map((ring) => ringTransformed(ring, m)), ...(geometry.paint ? { paint: paintTransformed(geometry.paint, m) } : {}), ...(geometry.strokePaint ? { strokePaint: paintTransformed(geometry.strokePaint, m) } : {}), }; } /** Writes a geometry straight into a shape – a gesture's preview, and the way back before its command. */ export function writeGeometry(shape: VectorShape, geometry: ShapeGeometry): void { shape.rings = geometry.rings; if (geometry.paint) shape.paint = geometry.paint; if (geometry.strokePaint && shape.stroke) shape.stroke.paint = geometry.strokePaint; } /** * How far the outlines of `shapes` reach, in canvas pixels; null when there is nothing with a size. * A curve counts as far as it is drawn, not as far as its handles stand out – the frame round a * circle touches the circle. A single point has no size and gets no frame. */ export function outlineExtent(shapes: readonly VectorShape[]): Extent | null { const e: Extent = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity }; const take = (p: Vec2): void => { e.x0 = Math.min(e.x0, p.x); e.y0 = Math.min(e.y0, p.y); e.x1 = Math.max(e.x1, p.x); e.y1 = Math.max(e.y1, p.y); }; for (const shape of shapes) for (const ring of shape.rings) { const count = ring.points.length; for (const [index, from] of ring.points.entries()) { take(from); if (index === count - 1 && !ring.closed) continue; const to = ring.points[(index + 1) % count]!; if (!from.out && !to.in) continue; const c1 = handleAt(from, 'out') ?? from; const c2 = handleAt(to, 'in') ?? to; for (const t of [ ...turningPoints(from.x, c1.x, c2.x, to.x), ...turningPoints(from.y, c1.y, c2.y, to.y), ]) take({ x: cubic(from.x, c1.x, c2.x, to.x, t), y: cubic(from.y, c1.y, c2.y, to.y, t) }); } } if (!Number.isFinite(e.x0)) return null; return e.x1 - e.x0 > 0 || e.y1 - e.y0 > 0 ? e : null; } function cubic(p0: number, p1: number, p2: number, p3: number, t: number): number { const u = 1 - t; return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3; } /** Where one coordinate of a cubic stops growing or shrinking, strictly between its ends. */ function turningPoints(p0: number, p1: number, p2: number, p3: number): number[] { const a = -p0 + 3 * p1 - 3 * p2 + p3; const b = 2 * (p0 - 2 * p1 + p2); const c = p1 - p0; const roots: number[] = []; if (Math.abs(a) < 1e-12) { if (Math.abs(b) > 1e-12) roots.push(-c / b); } else { const disc = b * b - 4 * a * c; if (disc >= 0) { const root = Math.sqrt(disc); roots.push((-b + root) / (2 * a), (-b - root) / (2 * a)); } } return roots.filter((t) => t > 0 && t < 1); } /** * The map a pull on a frame handle means: the handle on `side` of `extent` is taken to `to`, the * opposite side stays put. `proportional` keeps the shape of the frame – a corner follows the * farther of its two axes, an edge takes the other axis along about its middle. `centred` scales * about the middle instead of the opposite side. A side without an extent has nothing to scale and * keeps its factor at one; no side is ever pulled through its opposite. */ export function frameScale( extent: Extent, side: FrameSide, to: Vec2, options: { proportional?: boolean; centred?: boolean } = {}, ): Mat { const centre = extentCentre(extent); const axis = (s: -1 | 0 | 1, low: number, high: number, mid: number, reach: number) => { const anchor = s === 0 || options.centred ? mid : s > 0 ? low : high; if (s === 0) return { anchor, factor: 1 }; const size = high - low; const span = (s > 0 ? high : low) - anchor; if (Math.abs(span) < 1e-9) return { anchor, factor: 1 }; return { anchor, factor: Math.max((reach - anchor) / span, Math.min(1, MIN_FRAME_SIZE / size)) }; }; const x = axis(side.sx, extent.x0, extent.x1, centre.x, to.x); const y = axis(side.sy, extent.y0, extent.y1, centre.y, to.y); if (options.proportional) { const factor = side.sx === 0 ? y.factor : side.sy === 0 ? x.factor : Math.max(x.factor, y.factor); x.factor = factor; y.factor = factor; } return scaleAbout({ x: x.anchor, y: y.anchor }, x.factor, y.factor); } /** The map that puts the frame's left or top edge at `value`. */ export function frameMovedTo(extent: Extent, axis: 'x' | 'y', value: number): Mat { const dx = axis === 'x' ? value - extent.x0 : 0; const dy = axis === 'y' ? value - extent.y0 : 0; return { a: 1, b: 0, c: 0, d: 1, tx: dx, ty: dy }; } /** * The map that makes the frame `value` wide or high, its left top corner staying put; null where * there is nothing to stretch (a frame without that extent) or the size is not one. */ export function frameSizedTo(extent: Extent, axis: 'x' | 'y', value: number): Mat | null { const size = axis === 'x' ? extent.x1 - extent.x0 : extent.y1 - extent.y0; if (!(size > 0) || !Number.isFinite(value)) return null; const factor = Math.max(value, MIN_FRAME_SIZE) / size; return scaleAbout({ x: extent.x0, y: extent.y0 }, axis === 'x' ? factor : 1, axis === 'y' ? factor : 1); } /** Mirrors about the middle of the frame: `x` swaps left and right, `y` top and bottom. */ export function frameFlipped(extent: Extent, axis: 'x' | 'y'): Mat { return scaleAbout(extentCentre(extent), axis === 'x' ? -1 : 1, axis === 'y' ? -1 : 1); }