Files
feuerwerk/src/editor/sizeGizmo.ts
T
adminandClaude Opus 5.5 074bda2247 Give texts, tilemaps, particles and containers the turn knob (#242)
The knob on its stem turned and scaled only sprites and colliders. Every
other type with Scale and Rotation had to be typed into the inspector.

- Text, tilemap and particle source get the knob above their box. Edge
  and corner grips stay with sprite and collider, the only types with a
  width and a height of their own.
- A container gets a thin frame round all of its content, turned and
  scaled with it, and the knob sits above that frame. It turns and
  scales round the container's origin, so the content swings with it.
  A container with no content gets the knob above its symbol.
- contentFrame in sizeGizmo.ts is the pure rule for that frame.
  sizeGizmoEditor.ts wires it in.
- One pull is one undo step, as for sprites.

Not clicked in the browser: scaling (as opposed to turning) of a
container by the knob. Only the shared code path covers it.

Tests:
- ./check.sh: 178 files, 2573 tests, CHECK EXIT 0
- 2 new unit tests for contentFrame
- e2e 29k, 4 checks: knob on text, tilemap and particles, frame round
  the container's content, a real pull turns the container by 20.9
  degrees round its origin with the content on the computed pixel,
  one undo takes it back
- 4 counter-probes over #242 and #243 bite (2 unit, 2 browser)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:12:40 +02:00

184 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}