Size, turn and scale a selected sprite or collider in the scene (#223)
game-maker / gate (push) Successful in 2m54s
game-maker / suite (push) Successful in 9m14s

A sprite or collider could only be sized, turned and scaled through
the inspector. The scene view now draws a size gizmo on the selection,
so the common edits happen where the object is.

- Edges and corners set Width and Height. The opposite side stays
  where it was, so the position moves by half the change. Sizes land
  on whole units, at least 1.
- A circle collider has edge handles only and stays round. A polygon
  or frame collider keeps its own corner editing and gets no size
  handles.
- One knob on a stem above the top edge turns or scales. The first
  5 px of the drag decide which: out from the centre scales, sideways
  turns. The choice holds until the release, so one drag never does
  both. Turning and scaling share one handle by the user's choice.
- Shift snaps a turn to 15 degrees. The turn adds up the swept angle,
  so it goes on past a half circle instead of jumping back.
- The values are overrides of this placement, like "Fit to sprite".
  A value equal to the asset's own clears the override. One drag is
  one undo step.
- A handle reaches 7 px outwards but only half that into the object.
  Otherwise a press near the edge of a small sprite sizes it instead
  of moving it.
- The rules live in src/editor/sizeGizmo.ts without Pixi. The handles
  are drawn and driven by src/editor/viewport/sizeGizmoEditor.ts. The
  test hook __gm.sizeGizmo() reports them in screen pixels.

Found on the way:
- The first e2e run failed the play-mode drag check in 41b. It grabs
  the 40 px crate 6 px inside its bottom edge, and the full grip zone
  took that as a resize. The half zone inside fixes it.
- A first gizmo check failed because the sprite's right edge lay
  outside the scene view (x 940, view ends at 925). The test was
  wrong, the sprite now sits further left.

Tests:
- ./check.sh: 156 files, 2283 tests, CHECK EXIT 0 (before 2273).
- 10 unit tests in tests/editor/size-gizmo.test.ts.
- 6 new e2e checks in 29j: handles present, edge resize with the
  opposite edge fixed, undo, knob turn, knob scale that ignores a
  later sideways pull, circle radius.
- e2e 1-16,29,39-43 on the final state: ALL PASSED, 663 checks.
- Counter-checks: deciding the knob mode on every move fails the
  "does not turn it" check. Not moving the centre on an edge drag
  fails both edge checks. A full grip zone inside fails the new unit
  test. Originals restored.
- Check total 1357 + 6 = 1363, computed. CI measured 1357 at d257298.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 22:37:17 +02:00
1 parent d257298bc7
commit 32cb5b4ba5
8 files changed
+752 -7

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1357 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1363 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+2
View File
@@ -56,6 +56,8 @@ export function sceneHooks({ state, editor }: HookContext) {
gizmos: () => viewport.gizmoInfo(),
/** The corner handles of the polygon collider being edited, in global pixels (#151). */
polygonCorners: () => viewport.cornerInfo(),
/** The size gizmo on the selected sprite or collider, in global pixels (#223). */
sizeGizmo: () => viewport.sizeGizmoInfo(),
/** The rays the editor run cast in the last moment, as the Scene tab draws them, in world units (#153). */
rays: () => viewport.rayInfo(),
layerOf: (id: string) => viewport.layerOf(id),
+171
View File
@@ -0,0 +1,171 @@
/**
* 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 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 },
];
/** 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);
}
+33 -6
View File
@@ -39,6 +39,7 @@ import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
import { FreeViewport } from './FreeViewport';
import { PolygonCornerEditor } from './polygonCorners';
import { SizeGizmoEditor, type SizeGizmoInfo } from './sizeGizmoEditor';
import { MIN_GRID_STEP, type SceneViewHost } from './sceneViewHost';
import { type PaintTarget, TilemapPainter } from './tilemapPainting';
import { TilemapSolidOutlines } from './tilemapSolids';
@@ -116,11 +117,17 @@ export class SceneViewport extends FreeViewport {
const local = this.toLocal(global);
return screenToWorld(this.currentView(), local.x, local.y);
},
local: (global) => {
const local = this.toLocal(global);
return { x: local.x, y: local.y };
},
view: () => this.currentView(),
scale: () => this.currentView().scale,
redraw: () => this.redraw(),
};
private readonly painter = new TilemapPainter(this.host);
private readonly corners = new PolygonCornerEditor(this.host);
private readonly sizer = new SizeGizmoEditor(this.host);
private editorWorld: World | null = null;
private lastRays: CastRay[] = [];
private dragging: ObjectDrag | null = null;
@@ -208,7 +215,7 @@ export class SceneViewport extends FreeViewport {
this.startPan(e);
return;
}
if (e.button === 0 && this.corners.press(e)) return;
if (e.button === 0 && (this.corners.press(e) || this.sizer.press(e))) return;
if (e.button === 0 && e.target === this) this.pressEmpty(e);
}
@@ -233,7 +240,13 @@ export class SceneViewport extends FreeViewport {
this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e);
return;
}
if (this.panTo(e) || this.corners.move(e.global) || this.painter.move(e.global)) return;
if (
this.panTo(e) ||
this.corners.move(e.global) ||
this.sizer.move(e.global, e.shiftKey) ||
this.painter.move(e.global)
)
return;
if (this.dragging) {
const local = this.toLocal(e.global);
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
@@ -312,7 +325,7 @@ export class SceneViewport extends FreeViewport {
return;
}
this.endPan();
if (this.corners.release() || this.painter.release()) return;
if (this.corners.release() || this.sizer.release() || this.painter.release()) return;
const marquee = this.marquee;
this.marquee = null;
if (marquee) {
@@ -341,6 +354,11 @@ export class SceneViewport extends FreeViewport {
if (moves.length > 0) this.state.execute(oneStep('Move objects', moves));
}
/** The size gizmo's handles on the selection, where they are drawn (test hook). */
sizeGizmoInfo(): SizeGizmoInfo | null {
return this.sizer.info();
}
/** The corners of the polygon being edited, where they are drawn (test hook). */
cornerInfo(): { objectId: string; corners: Vec2[] } | null {
return this.corners.info();
@@ -357,7 +375,7 @@ export class SceneViewport extends FreeViewport {
return;
}
if (e.button !== 0) return;
if (this.corners.press(e)) return;
if (this.corners.press(e) || this.sizer.press(e)) return;
const holder = this.resolved();
if (!holder) return;
const stack = this.stackUnder(e);
@@ -402,7 +420,15 @@ export class SceneViewport extends FreeViewport {
private openMenuUnder(e: FederatedPointerEvent): void {
const source = this.holder();
const holder = this.resolved();
if (!source || !holder || this.dragging || this.painter.active || this.corners.active || this.marquee)
if (
!source ||
!holder ||
this.dragging ||
this.painter.active ||
this.corners.active ||
this.sizer.active ||
this.marquee
)
return;
const target = menuTargetOf(this.stackUnder(e), this.state.selectedObjectIds, this.primaryObjectId());
if (!target) return;
@@ -743,11 +769,12 @@ export class SceneViewport extends FreeViewport {
const primary = id === s.objectId;
this.lastGizmos.push({ id, primary, alpha });
this.gizmos.poly(corners, true).stroke({ width: 2, color: Theme.colors.gizmo, alpha });
if (!primary) continue;
if (!primary || this.sizer.handles(id)) continue;
for (const c of corners)
this.gizmos.rect(c.x - 3, c.y - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha });
}
}
this.sizer.draw(this.gizmos, view);
this.corners.draw(this.gizmos, view);
this.drawDropPreview(view);
this.painter.draw(this.gizmos, items, view);
+4
View File
@@ -1,6 +1,7 @@
import type { ObjectHolder } from '../../core/model/Project';
import type { Vec2 } from '../../core/model/Properties';
import type { DrawItem } from '../../viewport/items';
import type { View } from '../../viewport/view';
import type { EditorState } from '../state';
/** Below this on-screen spacing a grid is noise rather than orientation, so it is dropped. */
@@ -15,6 +16,9 @@ export interface SceneViewHost {
items(): readonly DrawItem[];
/** The world point under a global pointer position. */
worldAt(global: Vec2): Vec2;
/** A global pointer position in the view's own pixels, where `worldToScreen` puts things. */
local(global: Vec2): Vec2;
view(): View;
/** Screen pixels per world unit. */
scale(): number;
redraw(): void;
+315
View File
@@ -0,0 +1,315 @@
import type { FederatedPointerEvent, Graphics } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand';
import type { Command } from '../../core/commands/Command';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
SetOverrideCommand,
} from '../../core/commands/sceneObjectCommands';
import { objectRef, resolvedHolder } from '../../core/model/instance';
import {
apply as applyPoint,
applyVector,
determinant,
invert,
linearOf,
type Mat,
} from '../../core/model/matrix';
import type { Position, PropertyValue, Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { footprintCorners, footprintOf } from '../../core/properties/bounds';
import { parentTransform } from '../../core/properties/transform';
import { Theme } from '../../ui';
import type { DrawItem } from '../../viewport/items';
import { type View, worldToScreen } from '../../viewport/view';
import { FramePreview } from '../framePreview';
import {
knobAt,
type KnobMode,
knobMode,
resized,
scaled,
type Side,
type SizeGrip,
sizeGripAt,
swept,
turned,
} from '../sizeGizmo';
import type { SceneViewHost } from './sceneViewHost';
/** Screen pixels around a handle that grab it. */
const GRIP = 7;
/** Side of a size handle, and radius of the knob, in screen pixels. */
const HANDLE = 6;
const KNOB_RADIUS = 5;
/** How far the knob stands out from the top edge, in screen pixels. */
const STEM = 26;
/** Screen pixels a knob drag travels before it is decided whether it turns or scales. */
const DECIDE = 5;
/** Pixels of travel before a press on an edge or corner turns into a drag. */
const DRAG_THRESHOLD = 2;
/** Degrees a turn snaps to while Shift is held. */
const TURN_SNAP = 15;
/** The keys a drag writes; the preview puts the values straight on the object, the release a command. */
type Writes = Partial<Record<'width' | 'height' | 'radius' | 'rotation' | 'scale', number>>;
interface GizmoDrag {
holderId: string;
objectId: string;
grip: SizeGrip;
/** Undecided until the knob has travelled `DECIDE` pixels, then fixed until the release. */
mode: KnobMode | 'size' | null;
/** The object as the press found it, put back on release so one command records the edit. */
before: { position: Position; overrides: Record<string, PropertyValue | undefined> };
size: { w: number; h: number };
circle: boolean;
rotation: number;
scale: number;
/** The item's world transform at the press, and its inverse. */
m: Mat;
toOwn: Mat;
/** From a world delta to one in the parent's frame, where the position lives. */
toParent: Mat;
mirrored: boolean;
press: Vec2;
moved: boolean;
/** Where the pointer was at the last move, and the degrees it swept round the centre since the press. */
last: Vec2;
sweep: number;
writes: Writes;
position: Position;
}
/** The handles drawn on the selection, in global pixels, and what the running drag does (test hook). */
export interface SizeGizmoInfo {
objectId: string;
corners: Vec2[];
edges: Vec2[];
knob: Vec2;
mode: GizmoDrag['mode'];
}
/** Handles on the selected sprite or collider (#223): edges and corners size it, the knob turns or scales it. */
export class SizeGizmoEditor {
private drag: GizmoDrag | null = null;
private lastInfo: SizeGizmoInfo | null = null;
private readonly perFrame: FramePreview;
constructor(private readonly host: SceneViewHost) {
this.perFrame = new FramePreview(host.state, 'size-gizmo');
}
get active(): boolean {
return this.drag !== null;
}
/** The primary selection when it is a sprite or collider this view can edit. */
private edited(): DrawItem | null {
const state = this.host.state;
const s = state.selection;
const holder = this.host.holder();
if (s?.kind !== 'object' || !holder || !objectRef(state.project, holder, s.objectId)) return null;
const item = this.host.items().find((i) => i.id === s.objectId);
return item && (item.type === 'sprite' || item.type === 'collider') ? item : null;
}
/** Whether this item's corners are drawn by the gizmo, so the plain outline leaves them out. */
handles(id: string): boolean {
return this.edited()?.id === id;
}
private screenBox(item: DrawItem, view: View): { corners: Vec2[]; base: Vec2; knob: Vec2 } {
const corners = footprintCorners(item.type, item.props, item.m, 0, item.frame).map((c) =>
worldToScreen(view, c.x, c.y),
);
return { corners, ...knobAt(corners, STEM) };
}
/** A press on one of the handles starts its drag; true when it was one. */
press(e: FederatedPointerEvent): boolean {
const state = this.host.state;
const item = this.edited();
const holder = this.host.holder();
const ref = item && holder && objectRef(state.project, holder, item.id);
if (!item || !holder || !ref) return false;
const view = this.host.view();
const box = this.screenBox(item, view);
const shape = item.props.shape;
const circle = item.type === 'collider' && shape === 'circle';
const sizable = !(item.type === 'collider' && (shape === 'polygon' || shape === 'frame'));
const grip = sizeGripAt(box.corners, box.knob, this.host.local(e.global), GRIP, {
sizable,
corners: !circle,
});
if (!grip) return false;
e.stopPropagation();
const tree = resolvedHolder(state.project, holder);
const parent = parentTransform(state.project, tree, ref.object.parentId);
const size = footprintOf(item.type, item.props, item.frame);
const overrides: Record<string, PropertyValue | undefined> = {};
for (const key of ['width', 'height', 'radius', 'rotation', 'scale'])
overrides[key] = ref.object.overrides[key];
this.drag = {
holderId: holder.id,
objectId: item.id,
grip,
mode: grip.kind === 'size' ? 'size' : null,
before: { position: { ...ref.object.position }, overrides },
size,
circle,
rotation: Number(item.props.rotation ?? 0) || 0,
scale: Number(item.props.scale ?? 1) || 1,
m: item.m,
toOwn: invert(item.m),
toParent: invert(linearOf(parent.m)),
mirrored: determinant(parent.m) < 0,
press: this.host.worldAt(e.global),
moved: false,
last: this.host.worldAt(e.global),
sweep: 0,
writes: {},
position: { ...ref.object.position },
};
return true;
}
/** Follows the pointer with the handle under way; false when there is none. */
move(global: Vec2, snap: boolean): boolean {
const drag = this.drag;
if (!drag) return false;
const now = this.host.worldAt(global);
const centre = { x: drag.m.tx, y: drag.m.ty };
const travel = Math.hypot(now.x - drag.press.x, now.y - drag.press.y) * this.host.scale();
if (drag.mode === null) drag.mode = knobMode(centre, drag.press, now, DECIDE / this.host.scale());
if (drag.mode === null || (!drag.moved && travel < DRAG_THRESHOLD)) return true;
drag.moved = true;
drag.position = { ...drag.before.position };
if (drag.mode === 'size' && drag.grip.kind === 'size') {
const next = resized(drag.size, drag.grip.side, applyPoint(drag.toOwn, now), drag.circle);
drag.writes = drag.circle ? { radius: next.w / 2 } : { width: next.w, height: next.h };
const shift = applyVector(drag.toParent, applyVector(drag.m, next.shift));
drag.position = {
x: drag.before.position.x + shift.x,
y: drag.before.position.y + shift.y,
z: drag.before.position.z,
};
if (!sidesTouch(drag.grip.side, 'x')) delete drag.writes.width;
if (!sidesTouch(drag.grip.side, 'y')) delete drag.writes.height;
} else if (drag.mode === 'turn') {
drag.sweep += swept(centre, drag.last, now);
drag.last = now;
const sign = drag.mirrored ? -1 : 1;
drag.writes = { rotation: turned(drag.rotation, drag.sweep, sign, snap ? TURN_SNAP : 0) };
} else if (drag.mode === 'scale') {
drag.writes = { scale: scaled(drag.scale, centre, drag.press, now) };
}
this.preview();
return true;
}
private ref() {
const drag = this.drag;
const holder = this.host.holder();
return drag && holder ? objectRef(this.host.state.project, holder, drag.objectId) : undefined;
}
private preview(): void {
const drag = this.drag;
const ref = this.ref();
if (!drag || !ref) return;
for (const [key, value] of Object.entries(drag.writes)) ref.setOverride(key, value);
ref.setPosition(drag.position);
this.host.state.heldObjectIds = new Set([drag.objectId]);
this.perFrame.request();
}
/** Puts the preview back and lets one command do the edit, so one drag is one undo step. */
release(): boolean {
const drag = this.drag;
if (!drag) return false;
const ref = this.ref();
this.drag = null;
this.perFrame.cancel();
const state = this.host.state;
state.heldObjectIds = new Set();
if (!ref) return true;
for (const [key, value] of Object.entries(drag.before.overrides)) ref.setOverride(key, value);
ref.setPosition(drag.before.position);
const asset = findAsset(state.project, ref.object.assetId);
const commands: Command[] = [];
for (const [key, value] of Object.entries(drag.writes)) {
// A value equal to the asset's own clears the override, as "Fit to sprite" does (#222).
const same = !ref.target && asset?.props[key] === value;
commands.push(
same
? new ClearOverrideCommand(drag.holderId, drag.objectId, key)
: new SetOverrideCommand(drag.holderId, drag.objectId, key, value),
);
}
const p = drag.position;
const b = drag.before.position;
if (p.x !== b.x || p.y !== b.y)
commands.push(new MoveSceneObjectCommand(drag.holderId, drag.objectId, p));
if (commands.length > 0) state.execute(oneStep(labelOf(drag.mode), commands));
else state.preview();
return true;
}
/** The handles drawn for the selection, in global pixels, and what a drag of the knob does (test hook). */
info(): SizeGizmoInfo | null {
return this.lastInfo;
}
/** Corner and edge handles (not on a polygon, no corners on a circle) and the knob on its stem. */
draw(g: Graphics, view: View): void {
this.lastInfo = null;
const item = this.edited();
if (!item) return;
const { corners, base, knob } = this.screenBox(item, view);
const shape = item.props.shape;
const circle = item.type === 'collider' && shape === 'circle';
const sizable = !(item.type === 'collider' && (shape === 'polygon' || shape === 'frame'));
const edges = corners.map((a, i) => {
const b = corners[(i + 1) % corners.length]!;
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
});
const color = Theme.colors.gizmo;
g.moveTo(base.x, base.y).lineTo(knob.x, knob.y).stroke({ width: 1.5, color });
const busy = this.drag?.objectId === item.id && this.drag.grip.kind === 'knob';
g.circle(knob.x, knob.y, KNOB_RADIUS)
.fill(busy ? color : Theme.colors.accentText)
.stroke({ width: 1.5, color });
const square = (p: Vec2) =>
g
.rect(p.x - HANDLE / 2, p.y - HANDLE / 2, HANDLE, HANDLE)
.fill(Theme.colors.accentText)
.stroke({
width: 1.5,
color,
});
if (sizable) {
for (const p of edges) square(p);
if (!circle) for (const p of corners) square(p);
}
const global = (p: Vec2) => {
const q = g.toGlobal(p);
return { x: q.x, y: q.y };
};
this.lastInfo = {
objectId: item.id,
corners: sizable && !circle ? corners.map(global) : [],
edges: sizable ? edges.map(global) : [],
knob: global(knob),
mode: this.drag?.mode ?? null,
};
}
}
function sidesTouch(side: Side, axis: 'x' | 'y'): boolean {
return (axis === 'x' ? side.sx : side.sy) !== 0;
}
function labelOf(mode: GizmoDrag['mode']): string {
return mode === 'turn' ? 'Rotate object' : mode === 'scale' ? 'Scale object' : 'Resize object';
}
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { 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 = [
{ x: -50, y: -20 },
{ x: 50, y: -20 },
{ x: 50, y: 20 },
{ x: -50, y: 20 },
];
const ALL = { sizable: true, corners: true };
describe('size gizmo (#223)', () => {
it('puts the knob out from the middle of the top edge, turned with the box', () => {
expect(knobAt(BOX, 26)).toEqual({ base: { x: 0, y: -20 }, knob: { x: 0, y: -46 } });
// Turned a quarter clockwise: the top edge is now the right-hand one.
const turnedBox = [
{ x: 20, y: -50 },
{ x: 20, y: 50 },
{ x: -20, y: 50 },
{ x: -20, y: -50 },
];
const { knob } = knobAt(turnedBox, 26);
expect(knob.x).toBeCloseTo(46);
expect(knob.y).toBeCloseTo(0);
});
it('finds the knob first, then a corner, then an edge', () => {
const knob = { x: 0, y: -46 };
expect(sizeGripAt(BOX, knob, { x: 1, y: -45 }, 7, ALL)).toEqual({ kind: 'knob' });
expect(sizeGripAt(BOX, knob, { x: 48, y: 18 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 1, sy: 1 } });
expect(sizeGripAt(BOX, knob, { x: 10, y: 22 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } });
expect(sizeGripAt(BOX, knob, { x: -53, y: 0 }, 7, ALL)).toEqual({
kind: 'size',
side: { sx: -1, sy: 0 },
});
expect(sizeGripAt(BOX, knob, { x: 0, y: 0 }, 7, ALL)).toBeNull();
});
it('reaches only half as far into the box, so a small sprite can still be grabbed off its middle', () => {
const knob = { x: 0, y: -46 };
expect(sizeGripAt(BOX, knob, { x: 10, y: 26 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } });
expect(sizeGripAt(BOX, knob, { x: 10, y: 17 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } });
expect(sizeGripAt(BOX, knob, { x: 10, y: 14 }, 7, ALL)).toBeNull();
expect(sizeGripAt(BOX, knob, { x: 45, y: 15 }, 7, ALL)).toBeNull();
expect(sizeGripAt(BOX, knob, { x: 48, y: 18 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 1, sy: 1 } });
});
it('leaves the corners to the edges on a circle, and a polygon only its knob', () => {
const knob = { x: 0, y: -46 };
expect(sizeGripAt(BOX, knob, { x: 49, y: 17 }, 7, { sizable: true, corners: false })).toEqual({
kind: 'size',
side: { sx: 1, sy: 0 },
});
expect(sizeGripAt(BOX, knob, { x: 50, y: 0 }, 7, { sizable: false, corners: true })).toBeNull();
expect(sizeGripAt(BOX, knob, { x: 0, y: -46 }, 7, { sizable: false, corners: true })).toEqual({
kind: 'knob',
});
});
it('sizes from the dragged side and keeps the opposite one where it was', () => {
// Right edge from 50 out to 80: 30 wider, the centre 15 to the right, the left edge stays at -50.
expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: 80, y: 3 }, false)).toEqual({
w: 130,
h: 40,
shift: { x: 15, y: 0 },
});
// Top left corner in by 10 and 5.
expect(resized({ w: 100, h: 40 }, { sx: -1, sy: -1 }, { x: -40, y: -15 }, false)).toEqual({
w: 90,
h: 35,
shift: { x: 5, y: 2.5 },
});
});
it('rounds to whole units, never below one, and keeps a circle round', () => {
expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: 60.4, y: 0 }, false).w).toBe(110);
expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: -500, y: 0 }, false).w).toBe(1);
const circle = resized({ w: 40, h: 40 }, { sx: 0, sy: 1 }, { x: 0, y: 40 }, true);
expect(circle).toEqual({ w: 60, h: 60, shift: { x: 0, y: 10 } });
});
it('decides once between turning and scaling by the way the knob leaves the press', () => {
const centre = { x: 0, y: 0 };
const press = { x: 0, y: -46 };
expect(knobMode(centre, press, { x: 2, y: -46 }, 5)).toBeNull();
expect(knobMode(centre, press, { x: 12, y: -44 }, 5)).toBe('turn');
expect(knobMode(centre, press, { x: 3, y: -60 }, 5)).toBe('scale');
expect(knobMode(centre, press, { x: -2, y: -30 }, 5)).toBe('scale');
});
it('turns by the angle swept round the centre, the other way under a mirror, snapped with Shift', () => {
const centre = { x: 0, y: 0 };
const press = { x: 0, y: -46 };
const quarter = swept(centre, press, { x: 46, y: 0 });
expect(quarter).toBeCloseTo(90);
expect(turned(10, quarter, 1)).toBeCloseTo(100);
expect(turned(10, quarter, -1)).toBeCloseTo(-80);
expect(turned(0, swept(centre, press, { x: 46, y: -40 }), 1, 15)).toBe(45);
});
it('adds the sweeps of a drag up, so a turn past the half circle goes on instead of jumping back', () => {
const centre = { x: 0, y: 0 };
const path = [
{ x: 0, y: -46 },
{ x: 46, y: 0 },
{ x: 0, y: 46 },
{ x: -46, y: 1 },
];
let sweep = 0;
for (let i = 1; i < path.length; i++) sweep += swept(centre, path[i - 1]!, path[i]!);
expect(turned(0, sweep, 1)).toBeCloseTo(268.8, 1);
});
it('scales by how much farther from the centre the knob is pulled', () => {
const centre = { x: 0, y: 0 };
expect(scaled(1, centre, { x: 0, y: -40 }, { x: 0, y: -80 })).toBe(2);
expect(scaled(1.5, centre, { x: 0, y: -40 }, { x: 0, y: -20 })).toBe(0.75);
expect(scaled(1, centre, { x: 0, y: -40 }, { x: 0, y: 0 })).toBe(0.01);
});
});
+105
View File
@@ -236,6 +236,111 @@ export async function transform(t) {
);
await remove(RIG);
// 29j. The size gizmo (#223) on an upright 100×40 sprite: an edge sizes it with the opposite edge
// staying put, the knob above the top edge turns it when pulled across and scales it when
// pulled out – and a drag that starts one way stays that way.
await mutate(`
const scene = __gm.state.activeScene;
const make = (id, assetId, parentId, position, overrides = {}) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} });
scene.objects.push(
make('otf-size', ${JSON.stringify(SPRITE)}, '', { x: 150, y: ${LANE}, z: 960 }),
make('otf-sizec', 'collider', 'otf-size', { x: 0, y: 0, z: 1 }, { shape: 'circle', radius: 20 }),
)`);
await selectObject('otf-size');
const gizmo = () => json('__gm.sizeGizmo()');
// A press, moves through every point, a release: `drag` only goes in a straight line.
const path = async (points) => {
const [first, ...rest] = points;
await t.mouse('mouseMoved', first.x, first.y);
await t.mouse('mousePressed', first.x, first.y);
for (const p of rest) {
await t.mouse('mouseMoved', p.x, p.y, 'left', { buttons: 1 });
await sleep(16);
}
const last = rest.at(-1) ?? first;
await t.mouse('mouseReleased', last.x, last.y);
await t.settle();
};
const line = (from, to, steps = 10) =>
Array.from({ length: steps + 1 }, (_, i) => ({
x: from.x + ((to.x - from.x) * i) / steps,
y: from.y + ((to.y - from.y) * i) / steps,
}));
const sized = await object('otf-size');
const g0 = await gizmo();
const r0 = await rect('otf-size');
check(
'a selected sprite gets the size gizmo: four corners, four edges, and the knob above the top edge',
g0?.objectId === 'otf-size' &&
g0.corners.length === 4 &&
g0.edges.length === 4 &&
Math.abs(g0.knob.x - r0.x) < 2 &&
g0.knob.y < r0.y - r0.h / 2 - 10,
JSON.stringify({ g0, r0 }),
);
const right = g0.edges[1];
await path(line(right, { x: right.x + 30, y: right.y + 5 }));
const wide = { object: await object('otf-size'), rect: await rect('otf-size') };
const grow = 30 / s;
check(
'pulling the right edge 30 px out widens the sprite by 30 / scale and keeps its left edge where it was',
Math.abs(Number(wide.object.overrides.width) - Math.round(100 + grow)) < 0.01 &&
wide.object.overrides.height === undefined &&
Math.abs(wide.rect.x - wide.rect.w / 2 - (r0.x - r0.w / 2)) < 2 &&
wide.object.position.y === sized.position.y,
JSON.stringify({ wide, r0, grow }),
);
await undo();
check(
'one undo takes the whole resize back',
(await object('otf-size')).overrides.width === undefined && (await object('otf-size')).position.x === 150,
);
const k = (await gizmo()).knob;
const reach = k.y - r0.y;
await path(line(k, { x: k.x - reach, y: k.y }));
const spun = (await object('otf-size')).overrides;
check(
'pulling the knob across turns the sprite by the angle swept round its centre (45°) and leaves its scale',
Math.abs(Number(spun.rotation) - 45) < 1.5 && spun.scale === undefined,
JSON.stringify({ spun, reach }),
);
await undo();
const k2 = (await gizmo()).knob;
await path([
...line(k2, { x: k2.x, y: k2.y - 40 }),
...line({ x: k2.x, y: k2.y - 40 }, { x: k2.x + 60, y: k2.y - 40 }),
]);
const pulled = (await object('otf-size')).overrides;
const expected = Math.hypot(60, -reach + 40) / -reach;
check(
'pulling the knob out scales from the centre, and turning sideways afterwards does not turn it',
Math.abs(Number(pulled.scale) - expected) < 0.03 && pulled.rotation === undefined,
JSON.stringify({ pulled, expected }),
);
await undo();
await shot('29-size-gizmo');
await selectObject('otf-sizec');
const c0 = await gizmo();
const cRect = await rect('otf-sizec');
const cRight = c0.edges[1];
await path(line(cRight, { x: cRight.x + 20 * s, y: cRight.y }));
const circle = { object: await object('otf-sizec'), rect: await rect('otf-sizec') };
check(
'a circle collider has edges but no corners, and an edge pulled 20 out makes its radius 30',
c0.corners.length === 0 &&
c0.edges.length === 4 &&
circle.object.overrides.radius === 30 &&
Math.abs(circle.rect.x - circle.rect.w / 2 - (cRect.x - cRect.w / 2)) < 2,
JSON.stringify({ c0, cRect, circle }),
);
await undo();
await evaluate('__gm.state.select(null)');
await remove(['otf-size', 'otf-sizec']);
// 29i. Cleanup, and the view back where the earlier steps left the scene.
await remove([BOX, CHILD, CAMERA]);
await mutate(`