/** * The rules of the size gizmo (#223), without Pixi: which handle a press lands on, and what a drag * of it writes. Edges and corners size a sprite or collider with the opposite side staying put. One * knob outside the top edge turns or scales it – which of the two, the first stretch of the drag * decides, and it stays that way until the release, so a drag never does both. */ import { apply, boxCorners, determinant, extentOfPoints, invert, type Mat } from '../core/model/matrix'; import type { Vec2 } from '../core/model/Properties'; /** A side of the box in its own axes: -1, 0 or 1 on each, a corner has both. */ export interface Side { sx: -1 | 0 | 1; sy: -1 | 0 | 1; } export type SizeGrip = { kind: 'size'; side: Side } | { kind: 'knob' }; export type KnobMode = 'turn' | 'scale'; /** The four corners as `boxCorners` lists them: top left, top right, bottom right, bottom left. */ const CORNER_SIDES: readonly Side[] = [ { sx: -1, sy: -1 }, { sx: 1, sy: -1 }, { sx: 1, sy: 1 }, { sx: -1, sy: 1 }, ]; /** The edge from corner `i` to corner `i + 1`: top, right, bottom, left. */ const EDGE_SIDES: readonly Side[] = [ { sx: 0, sy: -1 }, { sx: 1, sy: 0 }, { sx: 0, sy: 1 }, { sx: -1, sy: 0 }, ]; /** * The frame round what a container holds (#242), in world units: the corners of its content boxed * in the container's own axes, so the frame turns and grows with it. Null when it holds nothing. */ export function contentFrame(m: Mat, content: readonly Vec2[]): Vec2[] | null { if (content.length === 0 || Math.abs(determinant(m)) < 1e-12) return null; const toOwn = invert(m); const e = extentOfPoints(content.map((p) => apply(toOwn, p))); return boxCorners(m, e.right - e.left, e.bottom - e.top, (e.left + e.right) / 2, (e.top + e.bottom) / 2); } /** Where the knob sits: `stem` pixels out from the middle of the top edge, away from the centre. */ export function knobAt(corners: readonly Vec2[], stem: number): { base: Vec2; knob: Vec2 } { const [a, b, c] = corners as [Vec2, Vec2, Vec2]; const base = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; const centre = { x: (a.x + c.x) / 2, y: (a.y + c.y) / 2 }; let out = { x: base.x - centre.x, y: base.y - centre.y }; // A box without height has its top edge on the centre: out is then square to that edge, or up. if (Math.hypot(out.x, out.y) < 1e-9) out = { x: b.y - a.y, y: a.x - b.x }; if (Math.hypot(out.x, out.y) < 1e-9) out = { x: 0, y: -1 }; const length = Math.hypot(out.x, out.y); return { base, knob: { x: base.x + (out.x / length) * stem, y: base.y + (out.y / length) * stem } }; } /** * The handle within `tolerance` of `p`, all in screen pixels: the knob first, then a corner, then an * edge. Inside the box a corner or edge reaches only half as far, so a small sprite can still be * grabbed and moved away from its middle. `sizable` false leaves the knob alone (a polygon keeps its * corners to itself), `corners` false the edges only (a circle has one size, a corner would ask for * two). */ export function sizeGripAt( corners: readonly Vec2[], knob: Vec2, p: Vec2, tolerance: number, options: { sizable: boolean; corners: boolean }, ): SizeGrip | null { if (Math.hypot(knob.x - p.x, knob.y - p.y) <= tolerance) return { kind: 'knob' }; if (!options.sizable) return null; let best: SizeGrip | null = null; let nearest = insideBox(corners, p) ? tolerance / 2 : tolerance; if (options.corners) for (const [i, corner] of corners.entries()) { const distance = Math.hypot(corner.x - p.x, corner.y - p.y); if (distance > nearest) continue; best = { kind: 'size', side: CORNER_SIDES[i]! }; nearest = distance; } if (best) return best; for (const [i, a] of corners.entries()) { const b = corners[(i + 1) % corners.length]!; const distance = distanceToSegment(p, a, b); if (distance > nearest) continue; best = { kind: 'size', side: EDGE_SIDES[i]! }; nearest = distance; } return best; } /** Whether `p` lies inside the four corners, whichever way round they run. */ function insideBox(corners: readonly Vec2[], p: Vec2): boolean { const sides = corners.map((a, i) => { const b = corners[(i + 1) % corners.length]!; return Math.sign((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)); }); return sides.every((side) => side >= 0) || sides.every((side) => side <= 0); } function distanceToSegment(p: Vec2, a: Vec2, b: Vec2): number { const d = { x: b.x - a.x, y: b.y - a.y }; const length = d.x * d.x + d.y * d.y; const t = length > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * d.x + (p.y - a.y) * d.y) / length)) : 0; return Math.hypot(a.x + d.x * t - p.x, a.y + d.y * t - p.y); } /** * The new size when the `side` of a `w` × `h` box, centred on its own origin, is dragged to `p` (in * the box's own units, measured before the drag): whole units, at least one, the opposite side where * it was. `shift` is how far the centre moves for that, in the same units. A circle stays round. */ export function resized( size: { w: number; h: number }, side: Side, p: Vec2, circle: boolean, ): { w: number; h: number; shift: Vec2 } { const along = (s: number, v: number, extent: number) => s === 0 ? extent : Math.max(1, Math.round(s * v + extent / 2)); let w = along(side.sx, p.x, size.w); let h = along(side.sy, p.y, size.h); if (circle) { const d = Math.max(2, 2 * Math.round((side.sx !== 0 ? w : h) / 2)); w = d; h = d; } return { w, h, shift: { x: side.sx === 0 ? 0 : (side.sx * (w - size.w)) / 2, y: side.sy === 0 ? 0 : (side.sy * (h - size.h)) / 2, }, }; } /** * What a knob drag does, decided once the pointer has left the press by `threshold`: along the line * from the centre through the press it scales, across it it turns. Null while it is still undecided. */ export function knobMode(centre: Vec2, press: Vec2, now: Vec2, threshold: number): KnobMode | null { const move = { x: now.x - press.x, y: now.y - press.y }; if (Math.hypot(move.x, move.y) < threshold) return null; const out = { x: press.x - centre.x, y: press.y - centre.y }; const length = Math.hypot(out.x, out.y) || 1; const radial = (move.x * out.x + move.y * out.y) / length; const across = (move.y * out.x - move.x * out.y) / length; return Math.abs(radial) >= Math.abs(across) ? 'scale' : 'turn'; } /** * The angle in degrees the pointer swept round `centre` going from `from` to `now`, the short way. * A drag adds these up move by move, so it can turn on past a half circle without jumping back. */ export function swept(centre: Vec2, from: Vec2, now: Vec2): number { const a = Math.atan2(from.y - centre.y, from.x - centre.x); const b = Math.atan2(now.y - centre.y, now.x - centre.x); const delta = ((b - a) * 180) / Math.PI; return ((((delta + 180) % 360) + 360) % 360) - 180; } /** * The rotation after sweeping `sweep` degrees, to one decimal. `sign` is -1 under a mirrored parent, * where the object's own turn runs the other way; `snap` holds it to steps of that many degrees. */ export function turned(start: number, sweep: number, sign: 1 | -1, snap = 0): number { const rotation = start + sign * sweep; if (snap > 0) return Math.round(rotation / snap) * snap; return Math.round(rotation * 10) / 10; } /** The scale after pulling the pointer from `press` to `now`, measured from `centre`: to 0.01, never below. */ export function scaled(start: number, centre: Vec2, press: Vec2, now: Vec2): number { const from = Math.hypot(press.x - centre.x, press.y - centre.y); const to = Math.hypot(now.x - centre.x, now.y - centre.y); if (from < 1e-9) return start; return Math.max(0.01, Math.round(start * (to / from) * 100) / 100); }