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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 12:12:40 +02:00
1 parent 98b9b300d9
commit 074bda2247
4 files changed
+196 -14

No files matched your search

+34 -1
View File
@@ -1,5 +1,15 @@
import { describe, expect, it } from 'vitest';
import { knobAt, knobMode, resized, scaled, sizeGripAt, swept, turned } from '../../src/editor/sizeGizmo';
import { apply, type Mat } from '../../src/core/model/matrix';
import {
contentFrame,
knobAt,
knobMode,
resized,
scaled,
sizeGripAt,
swept,
turned,
} from '../../src/editor/sizeGizmo';
// A 100 × 40 box centred on (0, 0), corners as `boxCorners` lists them.
const BOX = [
@@ -119,3 +129,26 @@ describe('size gizmo (#223)', () => {
expect(scaled(1, centre, { x: 0, y: -40 }, { x: 0, y: 0 })).toBe(0.01);
});
});
describe('the frame round a container (#242)', () => {
// Turned a quarter to the right and twice the size, standing at (100, 50).
const m: Mat = { a: 0, b: 2, c: -2, d: 0, tx: 100, ty: 50 };
const own = (x: number, y: number) => apply(m, { x, y });
it("boxes the content in the container's own axes, so it turns and grows with it", () => {
const content = [own(10, -20), own(30, -20), own(30, 0), own(10, 0), own(20, -10)];
const frame = contentFrame(m, content)!;
const expected = [own(10, -20), own(30, -20), own(30, 0), own(10, 0)];
frame.forEach((corner, i) => {
expect(corner.x).toBeCloseTo(expected[i]!.x);
expect(corner.y).toBeCloseTo(expected[i]!.y);
});
// The top edge in its own axes, where the knob stands, is on the world's right side now.
expect(knobAt(frame, 26).knob.x).toBeGreaterThan(Math.max(...frame.map((c) => c.x)));
});
it('has none without content or at no size', () => {
expect(contentFrame(m, [])).toBeNull();
expect(contentFrame({ ...m, b: 0, c: 0 }, [own(0, 0)])).toBeNull();
});
});